@equinor/fusion-framework-cli-plugin-ai-search 1.0.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/src/search.ts ADDED
@@ -0,0 +1,284 @@
1
+ import { createCommand, createOption } from 'commander';
2
+ import type { Document } from '@langchain/core/documents';
3
+ import { withAiOptions, type AiOptions } from './options/ai.js';
4
+
5
+ import { setupFramework } from './utils/setup-framework.js';
6
+ import { inspect } from 'node:util';
7
+ import type { RetrieverOptions } from '@equinor/fusion-framework-module-ai/lib';
8
+
9
+ /**
10
+ * Command options for the search command
11
+ */
12
+ type CommandOptions = AiOptions & {
13
+ /** Maximum number of search results to return */
14
+ limit: number;
15
+ /** Enable verbose output for debugging */
16
+ verbose: boolean;
17
+ /** OData filter expression for metadata-based filtering */
18
+ filter?: string;
19
+ /** Output results as JSON for programmatic use */
20
+ json: boolean;
21
+ /** Output raw metadata without normalization */
22
+ raw: boolean;
23
+ /** Search type: 'mmr' for Maximum Marginal Relevance or 'similarity' for similarity search */
24
+ searchType: 'mmr' | 'similarity';
25
+ };
26
+
27
+ /**
28
+ * Normalizes metadata attributes from Azure Search format to a flat object
29
+ * Azure Search returns metadata attributes as an array of {key, value} pairs,
30
+ * which this function converts to a simple key-value object for easier access
31
+ * @param metadata - Raw metadata object that may contain attributes array
32
+ * @returns Normalized metadata with attributes flattened into the root object
33
+ */
34
+ const normalizeMetadata = (metadata: Record<string, unknown>): Record<string, unknown> => {
35
+ const normalized = { ...metadata };
36
+
37
+ // Azure Search returns attributes as an array of {key, value} pairs
38
+ // Convert this to a flat object structure for easier access
39
+ if (Array.isArray(normalized.attributes)) {
40
+ const attributesObj: Record<string, unknown> = {};
41
+ for (const attr of normalized.attributes) {
42
+ // Validate attribute structure before processing
43
+ if (
44
+ typeof attr === 'object' &&
45
+ attr !== null &&
46
+ 'key' in attr &&
47
+ 'value' in attr &&
48
+ typeof attr.key === 'string'
49
+ ) {
50
+ // Try to parse JSON values (Azure Search stores complex values as JSON strings)
51
+ // Fall back to raw value if parsing fails (for string values)
52
+ try {
53
+ attributesObj[attr.key] = JSON.parse(attr.value as string);
54
+ } catch {
55
+ attributesObj[attr.key] = attr.value;
56
+ }
57
+ }
58
+ }
59
+ // Merge flattened attributes into the root metadata object
60
+ Object.assign(normalized, attributesObj);
61
+ // Remove the original attributes array to avoid duplication
62
+ delete normalized.attributes;
63
+ }
64
+
65
+ return normalized;
66
+ };
67
+
68
+ /**
69
+ * CLI command: `ai search`
70
+ *
71
+ * Search the vector store to validate embeddings and retrieve relevant documents.
72
+ *
73
+ * Features:
74
+ * - Semantic search using vector embeddings
75
+ * - Configurable result limits
76
+ * - Filter support for metadata-based filtering
77
+ * - JSON output option for programmatic use
78
+ * - Detailed result display with scores and metadata
79
+ *
80
+ * Usage:
81
+ * $ ffc ai search <query> [options]
82
+ *
83
+ * Options:
84
+ * --limit <number> Maximum number of results to return (default: 10)
85
+ * --search-type <type> Search type: 'mmr' or 'similarity' (default: similarity)
86
+ * --filter <expression> OData filter expression for metadata filtering
87
+ * --json Output results as JSON
88
+ * --raw Output raw metadata without normalization
89
+ * --verbose Enable verbose output
90
+ * --openai-api-key <key> API key for Azure OpenAI
91
+ * --openai-api-version <version> API version (default: 2024-02-15-preview)
92
+ * --openai-instance <name> Azure OpenAI instance name
93
+ * --openai-embedding-deployment <name> Azure OpenAI embedding deployment name
94
+ * --azure-search-endpoint <url> Azure Search endpoint URL
95
+ * --azure-search-api-key <key> Azure Search API key
96
+ * --azure-search-index-name <name> Azure Search index name
97
+ *
98
+ * Environment Variables:
99
+ * AZURE_OPENAI_API_KEY API key for Azure OpenAI
100
+ * AZURE_OPENAI_API_VERSION API version
101
+ * AZURE_OPENAI_INSTANCE_NAME Instance name
102
+ * AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME Embedding deployment name
103
+ * AZURE_SEARCH_ENDPOINT Azure Search endpoint
104
+ * AZURE_SEARCH_API_KEY Azure Search API key
105
+ * AZURE_SEARCH_INDEX_NAME Azure Search index name
106
+ *
107
+ * Examples:
108
+ * $ ffc ai search "how to use the framework"
109
+ * $ ffc ai search "authentication" --limit 5
110
+ * $ ffc ai search "typescript" --filter "metadata/source eq 'src/index.ts'"
111
+ * $ ffc ai search "documentation" --search-type similarity
112
+ * $ ffc ai search "documentation" --json
113
+ * $ ffc ai search "documentation" --json --raw
114
+ * $ ffc ai search "API reference" --verbose
115
+ */
116
+ export const command = withAiOptions(
117
+ createCommand('search')
118
+ .description('Search the vector store to validate embeddings and retrieve relevant documents')
119
+ .addOption(
120
+ createOption('--limit <number>', 'Maximum number of results to return')
121
+ .default(10)
122
+ .argParser(parseInt),
123
+ )
124
+ .addOption(
125
+ createOption('--search-type <type>', 'Search type: mmr or similarity')
126
+ .choices(['mmr', 'similarity'])
127
+ .default('similarity'),
128
+ )
129
+ .addOption(
130
+ createOption('--filter <expression>', 'OData filter expression for metadata filtering'),
131
+ )
132
+ .addOption(createOption('--json', 'Output results as JSON').default(false))
133
+ .addOption(createOption('--raw', 'Output raw metadata without normalization').default(false))
134
+ .addOption(createOption('--verbose', 'Enable verbose output').default(false))
135
+ .argument('<query>', 'Search query string')
136
+ .action(async (query: string, options: CommandOptions) => {
137
+ if (options.verbose) {
138
+ console.log('🔍 Initializing framework...');
139
+ }
140
+
141
+ const framework = await setupFramework(options);
142
+
143
+ if (!options.azureSearchIndexName) {
144
+ throw new Error('Azure Search index name is required');
145
+ }
146
+
147
+ if (options.verbose) {
148
+ console.log('✅ Framework initialized successfully');
149
+ console.log(`🔎 Searching for: "${query}"`);
150
+ console.log(`📊 Limit: ${options.limit}`);
151
+ console.log(`🔍 Search type: ${options.searchType}`);
152
+ if (options.filter) {
153
+ console.log(`🔧 Filter: ${options.filter}`);
154
+ }
155
+ console.log('');
156
+ }
157
+
158
+ const vectorStoreService = framework.ai.getService('search', options.azureSearchIndexName);
159
+
160
+ try {
161
+ // Configure retriever options for semantic search
162
+ // The retriever provides more control over search parameters than direct vector store queries
163
+ // RetrieverOptions is a discriminated union, so we construct it based on search type
164
+ const filter = options.filter
165
+ ? {
166
+ filterExpression: options.filter,
167
+ }
168
+ : undefined;
169
+
170
+ // Construct retriever options based on search type
171
+ // MMR search requires additional parameters for diversity optimization
172
+ // Similarity search doesn't need these parameters
173
+ const retrieverOptions: RetrieverOptions =
174
+ options.searchType === 'mmr'
175
+ ? {
176
+ k: options.limit,
177
+ searchType: 'mmr',
178
+ ...(filter && { filter: filter as Record<string, unknown> }),
179
+ }
180
+ : {
181
+ k: options.limit,
182
+ searchType: 'similarity',
183
+ ...(filter && { filter: filter as Record<string, unknown> }),
184
+ };
185
+
186
+ // Create retriever and execute search query
187
+ const retriever = vectorStoreService.asRetriever(retrieverOptions);
188
+ const results = await retriever.invoke(query);
189
+
190
+ // Validate that results is an array
191
+ if (!results || !Array.isArray(results)) {
192
+ throw new Error(
193
+ `Invalid search results: expected array but got ${results === null ? 'null' : typeof results}`,
194
+ );
195
+ }
196
+
197
+ if (options.json) {
198
+ // Output as JSON for programmatic use (e.g., piping to other tools)
199
+ for (const doc of results) {
200
+ if (options.raw) {
201
+ // Output raw document structure with full depth inspection
202
+ console.log(inspect(doc, { depth: null, colors: true }));
203
+ } else {
204
+ // Output normalized metadata for cleaner JSON structure
205
+ const metadata = normalizeMetadata(doc.metadata as Record<string, unknown>);
206
+ console.log({
207
+ content: doc.pageContent,
208
+ metadata,
209
+ score: (metadata as { score?: number })?.score,
210
+ });
211
+ }
212
+ }
213
+ } else {
214
+ // Format results for human-readable output
215
+ if (results.length === 0) {
216
+ console.log('❌ No results found');
217
+ return;
218
+ }
219
+
220
+ console.log(`✅ Found ${results.length} result${results.length !== 1 ? 's' : ''}:\n`);
221
+
222
+ results.forEach((doc: Document, index: number) => {
223
+ // Normalize metadata to flatten attributes array unless --raw flag is set
224
+ // Raw mode preserves Azure Search's original metadata structure
225
+ const processedMetadata = options.raw
226
+ ? (doc.metadata as Record<string, unknown>)
227
+ : normalizeMetadata(doc.metadata as Record<string, unknown>);
228
+ const metadata = processedMetadata as {
229
+ source?: string;
230
+ score?: number;
231
+ [key: string]: unknown;
232
+ };
233
+ const score = metadata.score;
234
+ const source = metadata.source || 'Unknown source';
235
+
236
+ // Display result header with score if available
237
+ console.log(`${'─'.repeat(80)}`);
238
+ console.log(
239
+ `Result ${index + 1}${score !== undefined ? ` (Score: ${score.toFixed(4)})` : ''}`,
240
+ );
241
+ console.log(`Source: ${source}`);
242
+
243
+ // Display additional metadata fields if verbose mode is enabled
244
+ // Exclude source and score from additional metadata to avoid duplication
245
+ if (options.verbose) {
246
+ const { source: _, score: __, ...otherMetadata } = metadata;
247
+ if (Object.keys(otherMetadata).length > 0) {
248
+ console.log(`Metadata:`, JSON.stringify(otherMetadata, null, 2));
249
+ }
250
+ }
251
+ console.log('');
252
+
253
+ // Truncate content if too long to keep output readable
254
+ // Full content is still available in JSON mode
255
+ const content = doc.pageContent;
256
+ const maxLength = 500;
257
+ if (content.length > maxLength) {
258
+ console.log(`${content.substring(0, maxLength)}...`);
259
+ console.log(`\n[Content truncated - ${content.length} characters total]`);
260
+ } else {
261
+ console.log(content);
262
+ }
263
+ console.log('');
264
+ });
265
+
266
+ console.log(`${'─'.repeat(80)}`);
267
+ }
268
+ } catch (error) {
269
+ console.error(
270
+ `❌ Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
271
+ );
272
+ if (options.verbose && error instanceof Error && error.stack) {
273
+ console.error(error.stack);
274
+ }
275
+ process.exit(1);
276
+ }
277
+ }),
278
+ {
279
+ includeEmbedding: true,
280
+ includeSearch: true,
281
+ },
282
+ );
283
+
284
+ export default command;
@@ -0,0 +1,75 @@
1
+ import { enableAI, type IAIConfigurator, type AIModule } from '@equinor/fusion-framework-module-ai';
2
+
3
+ import {
4
+ AzureOpenAiEmbed,
5
+ AzureOpenAIModel,
6
+ AzureVectorStore,
7
+ } from '@equinor/fusion-framework-module-ai/azure';
8
+
9
+ import type { AiOptions } from '../options/ai.js';
10
+ import { ModulesConfigurator, type ModulesInstance } from '@equinor/fusion-framework-module';
11
+
12
+ /**
13
+ * Initializes and configures the Fusion Framework with AI module capabilities
14
+ * @param options - AI configuration options including API keys, deployments, and vector store settings
15
+ * @returns Promise resolving to an initialized framework instance with AI module
16
+ * @throws {Error} If embedding deployment is required but not provided when configuring vector store
17
+ */
18
+ export const setupFramework = async (options: AiOptions): Promise<ModulesInstance<[AIModule]>> => {
19
+ // Create a new module configurator for the framework
20
+ const configurator = new ModulesConfigurator<[AIModule]>();
21
+
22
+ // Configure AI module with provided options
23
+ enableAI(configurator, (aiConfig: IAIConfigurator) => {
24
+ // Configure chat model if deployment name is provided
25
+ if (options.openaiChatDeployment) {
26
+ aiConfig.setModel(
27
+ options.openaiChatDeployment,
28
+ new AzureOpenAIModel({
29
+ azureOpenAIApiKey: options.openaiApiKey,
30
+ azureOpenAIApiDeploymentName: options.openaiChatDeployment,
31
+ azureOpenAIApiInstanceName: options.openaiInstance,
32
+ azureOpenAIApiVersion: options.openaiApiVersion,
33
+ }),
34
+ );
35
+ }
36
+
37
+ // Configure embedding model if deployment name is provided
38
+ if (options.openaiEmbeddingDeployment) {
39
+ aiConfig.setEmbedding(
40
+ options.openaiEmbeddingDeployment,
41
+ new AzureOpenAiEmbed({
42
+ azureOpenAIApiKey: options.openaiApiKey,
43
+ azureOpenAIApiDeploymentName: options.openaiEmbeddingDeployment,
44
+ azureOpenAIApiInstanceName: options.openaiInstance,
45
+ azureOpenAIApiVersion: options.openaiApiVersion,
46
+ }),
47
+ );
48
+ }
49
+
50
+ // Configure vector store if Azure Search options are provided
51
+ // Vector store requires an embedding service to generate embeddings for documents
52
+ if (options.azureSearchEndpoint && options.azureSearchApiKey && options.azureSearchIndexName) {
53
+ if (!options.openaiEmbeddingDeployment) {
54
+ throw new Error('Embedding deployment is required to configure the vector store');
55
+ }
56
+
57
+ // Retrieve the embedding service to pass to the vector store
58
+ // The vector store uses embeddings to index and search documents
59
+ const embeddingService = aiConfig.getService('embeddings', options.openaiEmbeddingDeployment);
60
+
61
+ aiConfig.setVectorStore(
62
+ options.azureSearchIndexName,
63
+ new AzureVectorStore(embeddingService, {
64
+ endpoint: options.azureSearchEndpoint,
65
+ key: options.azureSearchApiKey,
66
+ indexName: options.azureSearchIndexName,
67
+ }),
68
+ );
69
+ }
70
+ });
71
+
72
+ // Initialize the framework with all configured modules
73
+ const framework = await configurator.initialize();
74
+ return framework;
75
+ };
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Generated by genversion.
2
+ export const version = '1.0.0';
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist/esm",
7
+ "rootDir": "src",
8
+ "declarationDir": "./dist/types",
9
+ "baseUrl": "."
10
+ },
11
+ "references": [
12
+ {
13
+ "path": "../ai-base"
14
+ },
15
+ {
16
+ "path": "../../modules/ai"
17
+ },
18
+ {
19
+ "path": "../../modules/module"
20
+ }
21
+ ],
22
+ "include": ["src/**/*"],
23
+ "exclude": ["node_modules"]
24
+ }