@memberjunction/server 5.23.0 → 5.25.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 (63) hide show
  1. package/dist/agents/skip-sdk.d.ts +12 -0
  2. package/dist/agents/skip-sdk.d.ts.map +1 -1
  3. package/dist/agents/skip-sdk.js +70 -1
  4. package/dist/agents/skip-sdk.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +11 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/generated/generated.d.ts +954 -0
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +26108 -20749
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  13. package/dist/generic/RunViewResolver.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/resolvers/ArtifactFileResolver.d.ts +15 -0
  19. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -0
  20. package/dist/resolvers/ArtifactFileResolver.js +74 -0
  21. package/dist/resolvers/ArtifactFileResolver.js.map +1 -0
  22. package/dist/resolvers/AutotagPipelineResolver.d.ts +23 -1
  23. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  24. package/dist/resolvers/AutotagPipelineResolver.js +197 -13
  25. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  26. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  27. package/dist/resolvers/FetchEntityVectorsResolver.js +6 -2
  28. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  29. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  30. package/dist/resolvers/FileResolver.js +12 -32
  31. package/dist/resolvers/FileResolver.js.map +1 -1
  32. package/dist/resolvers/GeoResolver.d.ts +58 -0
  33. package/dist/resolvers/GeoResolver.d.ts.map +1 -0
  34. package/dist/resolvers/GeoResolver.js +302 -0
  35. package/dist/resolvers/GeoResolver.js.map +1 -0
  36. package/dist/resolvers/RunAIAgentResolver.d.ts +34 -1
  37. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  38. package/dist/resolvers/RunAIAgentResolver.js +183 -48
  39. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  40. package/dist/resolvers/SearchKnowledgeResolver.d.ts +23 -41
  41. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  42. package/dist/resolvers/SearchKnowledgeResolver.js +133 -382
  43. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  44. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts +19 -0
  45. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -0
  46. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +149 -0
  47. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -0
  48. package/package.json +63 -63
  49. package/src/__tests__/search-knowledge-tags.test.ts +255 -0
  50. package/src/__tests__/skip-sdk-organic-keys.test.ts +274 -0
  51. package/src/agents/skip-sdk.ts +83 -2
  52. package/src/config.ts +11 -0
  53. package/src/generated/generated.ts +3690 -1
  54. package/src/generic/RunViewResolver.ts +1 -0
  55. package/src/index.ts +2 -0
  56. package/src/resolvers/ArtifactFileResolver.ts +71 -0
  57. package/src/resolvers/AutotagPipelineResolver.ts +213 -10
  58. package/src/resolvers/FetchEntityVectorsResolver.ts +6 -2
  59. package/src/resolvers/FileResolver.ts +12 -41
  60. package/src/resolvers/GeoResolver.ts +258 -0
  61. package/src/resolvers/RunAIAgentResolver.ts +229 -76
  62. package/src/resolvers/SearchKnowledgeResolver.ts +118 -462
  63. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +138 -0
@@ -1,12 +1,8 @@
1
1
  import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
- import { LogError, LogStatus, Metadata, RunView, UserInfo, ComputeRRF, ScoredCandidate, EntityRecordNameInput, CompositeKey } from '@memberjunction/core';
4
- import { MJVectorIndexEntity, MJVectorDatabaseEntity } from '@memberjunction/core-entities';
3
+ import { LogError, LogStatus } from '@memberjunction/core';
5
4
  import { ResolverBase } from '../generic/ResolverBase.js';
6
- import { AIEngine } from '@memberjunction/aiengine';
7
- import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
8
- import { VectorDBBase, BaseResponse } from '@memberjunction/ai-vectordb';
9
- import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
5
+ import { SearchEngine, SearchResult as SearchEngineResult, SearchResultItem as SearchEngineResultItem, SearchProviderInfo } from '@memberjunction/search-engine';
10
6
 
11
7
  /* ───── GraphQL types ───── */
12
8
 
@@ -20,6 +16,9 @@ export class SearchScoreBreakdown {
20
16
 
21
17
  @Field(() => Float, { nullable: true })
22
18
  Entity?: number;
19
+
20
+ @Field(() => Float, { nullable: true })
21
+ Storage?: number;
23
22
  }
24
23
 
25
24
  @ObjectType()
@@ -59,6 +58,26 @@ export class SearchKnowledgeResultItem {
59
58
 
60
59
  @Field()
61
60
  MatchedAt: Date;
61
+
62
+ /** Raw vector metadata as JSON string — contains all entity fields stored in the vector DB */
63
+ @Field({ nullable: true })
64
+ RawMetadata?: string;
65
+
66
+ /** Discriminator for UI rendering: 'entity-record', 'storage-file', or 'content-item' */
67
+ @Field()
68
+ ResultType: string;
69
+
70
+ /** ID of the SearchProvider metadata record that produced this result */
71
+ @Field({ nullable: true })
72
+ ProviderId?: string;
73
+
74
+ /** Display label from the SearchProvider metadata (e.g., "Database", "Semantic Search") */
75
+ @Field({ nullable: true })
76
+ ProviderLabel?: string;
77
+
78
+ /** Font Awesome icon class from the SearchProvider metadata */
79
+ @Field({ nullable: true })
80
+ ProviderIcon?: string;
62
81
  }
63
82
 
64
83
  @ObjectType()
@@ -71,6 +90,30 @@ export class SearchSourceCounts {
71
90
 
72
91
  @Field()
73
92
  Entity: number;
93
+
94
+ @Field()
95
+ Storage: number;
96
+ }
97
+
98
+ @ObjectType()
99
+ export class SearchProviderInfoType {
100
+ @Field()
101
+ ID: string;
102
+
103
+ @Field()
104
+ Name: string;
105
+
106
+ @Field()
107
+ DisplayName: string;
108
+
109
+ @Field()
110
+ Icon: string;
111
+
112
+ @Field()
113
+ SourceType: string;
114
+
115
+ @Field()
116
+ Priority: number;
74
117
  }
75
118
 
76
119
  @ObjectType()
@@ -90,6 +133,9 @@ export class SearchKnowledgeResult {
90
133
  @Field(() => SearchSourceCounts)
91
134
  SourceCounts: SearchSourceCounts;
92
135
 
136
+ @Field(() => [SearchProviderInfoType])
137
+ Providers: SearchProviderInfoType[];
138
+
93
139
  @Field({ nullable: true })
94
140
  ErrorMessage?: string;
95
141
  }
@@ -106,7 +152,7 @@ export class SearchFiltersInput {
106
152
  Tags?: string[];
107
153
  }
108
154
 
109
- /* ───── Resolver ───── */
155
+ /* ───── Resolver (thin wrapper around SearchEngine) ───── */
110
156
 
111
157
  @Resolver()
112
158
  export class SearchKnowledgeResolver extends ResolverBase {
@@ -126,53 +172,18 @@ export class SearchKnowledgeResolver extends ResolverBase {
126
172
  return this.errorResult('Unable to determine current user', startTime);
127
173
  }
128
174
 
129
- if (!query.trim()) {
130
- return this.errorResult('Query cannot be empty', startTime);
131
- }
132
-
133
- const topK = maxResults ?? 20;
134
-
135
- let t0 = Date.now();
136
- await AIEngine.Instance.Config(false, currentUser);
137
- LogStatus(`SearchKnowledge: AIEngine.Config: ${Date.now() - t0}ms`);
138
-
139
- // Run vector search and full-text search in parallel
140
- t0 = Date.now();
141
- const [vectorResults, fullTextResults] = await Promise.all([
142
- this.searchAllVectorIndexes(query, topK, filters, currentUser),
143
- this.searchFullText(query, topK, filters, currentUser)
144
- ]);
145
- LogStatus(`SearchKnowledge: Vector(${vectorResults.length}) + FTS(${fullTextResults.length}): ${Date.now() - t0}ms`);
146
-
147
- // Fuse results with RRF if we have results from multiple sources
148
- t0 = Date.now();
149
- const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
150
- const dedupedResults = this.deduplicateResults(fusedResults);
151
-
152
- // Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
153
- const scoreThreshold = minScore ?? 0;
154
- const filteredResults = scoreThreshold > 0
155
- ? dedupedResults.filter(r => r.Score >= scoreThreshold)
156
- : dedupedResults;
157
- LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
158
-
159
- // Enrich with entity icons and record names
160
- t0 = Date.now();
161
- await this.enrichResults(filteredResults, currentUser);
162
- LogStatus(`SearchKnowledge: Enrich (icons + names): ${Date.now() - t0}ms`);
163
- LogStatus(`SearchKnowledge: Total: ${Date.now() - startTime}ms`);
164
-
165
- return {
166
- Success: true,
167
- Results: filteredResults,
168
- TotalCount: filteredResults.length,
169
- ElapsedMs: Date.now() - startTime,
170
- SourceCounts: {
171
- Vector: vectorResults.length,
172
- FullText: fullTextResults.length,
173
- Entity: 0
174
- },
175
- };
175
+ const result = await SearchEngine.Instance.Search({
176
+ Query: query,
177
+ MaxResults: maxResults,
178
+ MinScore: minScore,
179
+ Filters: filters ? {
180
+ EntityNames: filters.EntityNames,
181
+ SourceTypes: filters.SourceTypes,
182
+ Tags: filters.Tags
183
+ } : undefined
184
+ }, currentUser);
185
+
186
+ return this.mapSearchResult(result);
176
187
  } catch (error) {
177
188
  const msg = error instanceof Error ? error.message : String(error);
178
189
  LogError(`SearchKnowledge mutation failed: ${msg}`);
@@ -180,425 +191,69 @@ export class SearchKnowledgeResolver extends ResolverBase {
180
191
  }
181
192
  }
182
193
 
183
- /**
184
- * Search ALL vector indexes. Groups indexes by embedding model, embeds the query
185
- * in parallel per model, then queries all indexes per model in parallel as each
186
- * embedding completes. Maximum concurrency at every level.
187
- */
188
- private async searchAllVectorIndexes(
189
- query: string,
190
- topK: number,
191
- filters: SearchFiltersInput | undefined,
192
- contextUser: UserInfo
193
- ): Promise<SearchKnowledgeResultItem[]> {
194
- const rv = new RunView();
195
-
196
- const indexResult = await rv.RunView<MJVectorIndexEntity>({
197
- EntityName: 'MJ: Vector Indexes',
198
- ResultType: 'entity_object'
199
- }, contextUser);
200
-
201
- if (!indexResult.Success || indexResult.Results.length === 0) {
202
- LogStatus('SearchKnowledge: No vector indexes configured');
203
- return [];
204
- }
205
-
206
- // Group indexes by EmbeddingModelID
207
- const indexesByModel = this.groupIndexesByModel(indexResult.Results);
208
- const pineFilter = this.buildPineconeFilter(filters);
209
-
210
- // For each model group: embed query + query all indexes — all in parallel
211
- const modelGroupPromises = Array.from(indexesByModel.entries()).map(
212
- ([embeddingModelID, indexes]) =>
213
- this.embedAndQueryGroup(query, embeddingModelID, indexes, topK, pineFilter, contextUser)
214
- );
215
-
216
- const groupResults = await Promise.all(modelGroupPromises);
217
- return groupResults.flat();
218
- }
219
-
220
- /** Group vector indexes by their EmbeddingModelID */
221
- private groupIndexesByModel(indexes: MJVectorIndexEntity[]): Map<string, MJVectorIndexEntity[]> {
222
- const groups = new Map<string, MJVectorIndexEntity[]>();
223
- for (const index of indexes) {
224
- const modelId = index.EmbeddingModelID;
225
- const existing = groups.get(modelId);
226
- if (existing) {
227
- existing.push(index);
228
- } else {
229
- groups.set(modelId, [index]);
230
- }
231
- }
232
- return groups;
233
- }
234
-
235
- /**
236
- * Embed query with one model, then immediately query all indexes that use that model.
237
- * The embedding and subsequent queries are chained so index queries fire as soon as
238
- * the embedding completes — without waiting for other models' embeddings.
239
- */
240
- private async embedAndQueryGroup(
241
- query: string,
242
- embeddingModelID: string,
243
- indexes: MJVectorIndexEntity[],
244
- topK: number,
245
- filter: object | undefined,
246
- contextUser: UserInfo
247
- ): Promise<SearchKnowledgeResultItem[]> {
194
+ @Mutation(() => SearchKnowledgeResult)
195
+ async PreviewSearch(
196
+ @Arg('query') query: string,
197
+ @Arg('maxResults', () => Float, { nullable: true, defaultValue: 8 }) maxResults: number,
198
+ @Ctx() { userPayload }: AppContext = {} as AppContext
199
+ ): Promise<SearchKnowledgeResult> {
200
+ const startTime = Date.now();
248
201
  try {
249
- // Find the AI model for this embedding
250
- const model = AIEngine.Instance.Models.find(m => UUIDsEqual(m.ID, embeddingModelID));
251
- if (!model) {
252
- LogError(`SearchKnowledge: Embedding model ${embeddingModelID} not found`);
253
- return [];
254
- }
255
-
256
- // Create embedding instance
257
- const apiKey = GetAIAPIKey(model.DriverClass);
258
- const embedding = MJGlobal.Instance.ClassFactory.CreateInstance<BaseEmbeddings>(
259
- BaseEmbeddings, model.DriverClass, apiKey
260
- );
261
- if (!embedding) {
262
- LogError(`SearchKnowledge: Failed to create embedding for ${model.DriverClass}`);
263
- return [];
202
+ const currentUser = this.GetUserFromPayload(userPayload);
203
+ if (!currentUser) {
204
+ return this.errorResult('Unable to determine current user', startTime);
264
205
  }
265
206
 
266
- // Embed the query with this model
267
- const embedResult = await embedding.EmbedText({ text: query, model: '' });
268
- if (!embedResult?.vector?.length) {
269
- LogError(`SearchKnowledge: Failed to embed with ${model.Name}`);
270
- return [];
271
- }
207
+ const result = await SearchEngine.Instance.PreviewSearch(query, maxResults, currentUser);
272
208
 
273
- // Query all indexes for this model in parallel
274
- const indexPromises = indexes.map(vectorIndex =>
275
- this.queryOneIndex(vectorIndex, embedResult.vector, topK, filter, contextUser)
276
- .catch(error => {
277
- LogError(`SearchKnowledge: Error querying index "${vectorIndex.Name}": ${error}`);
278
- return [] as SearchKnowledgeResultItem[];
279
- })
280
- );
281
-
282
- const indexResults = await Promise.all(indexPromises);
283
- return indexResults.flat();
209
+ return this.mapSearchResult(result);
284
210
  } catch (error) {
285
- LogError(`SearchKnowledge: Error in embedding group ${embeddingModelID}: ${error}`);
286
- return [];
287
- }
288
- }
289
-
290
- /**
291
- * Query a single vector index by looking up its VectorDatabase provider and passing the index name.
292
- */
293
- private async queryOneIndex(
294
- vectorIndex: MJVectorIndexEntity,
295
- queryVector: number[],
296
- topK: number,
297
- filter: object | undefined,
298
- contextUser: UserInfo
299
- ): Promise<SearchKnowledgeResultItem[]> {
300
- // Look up the vector database to get the ClassKey
301
- const rv = new RunView();
302
- const dbResult = await rv.RunView<MJVectorDatabaseEntity>({
303
- EntityName: 'MJ: Vector Databases',
304
- ExtraFilter: `ID='${vectorIndex.VectorDatabaseID}'`,
305
- ResultType: 'entity_object'
306
- }, contextUser);
307
-
308
- if (!dbResult.Success || dbResult.Results.length === 0) {
309
- LogError(`SearchKnowledge: VectorDatabase not found for index "${vectorIndex.Name}"`);
310
- return [];
311
- }
312
-
313
- const vectorDB = dbResult.Results[0];
314
- const apiKey = GetAIAPIKey(vectorDB.ClassKey);
315
- const vectorDBInstance = MJGlobal.Instance.ClassFactory.CreateInstance<VectorDBBase>(
316
- VectorDBBase, vectorDB.ClassKey, apiKey
317
- );
318
-
319
- if (!vectorDBInstance) {
320
- LogError(`SearchKnowledge: Failed to create VectorDB instance for "${vectorDB.ClassKey}"`);
321
- return [];
322
- }
323
-
324
- // Query with the specific index name
325
- const response: BaseResponse = await vectorDBInstance.QueryIndex({
326
- id: vectorIndex.Name,
327
- vector: queryVector,
328
- topK,
329
- includeMetadata: true,
330
- filter,
331
- });
332
-
333
- if (!response.success || !response.data?.matches) {
334
- return [];
211
+ const msg = error instanceof Error ? error.message : String(error);
212
+ LogError(`PreviewSearch mutation failed: ${msg}`);
213
+ return this.errorResult(msg, startTime);
335
214
  }
336
-
337
- return this.convertMatches(response.data.matches, vectorIndex.Name);
338
215
  }
339
216
 
340
- /**
341
- * Full-text search using the MJCore Metadata.FullTextSearch() method.
342
- * Delegates to the provider stack which uses database-native FTS
343
- * (SQL Server FREETEXT, PostgreSQL tsvector) via RunView + UserSearchString.
344
- */
345
- private async searchFullText(
346
- query: string,
347
- topK: number,
348
- filters: SearchFiltersInput | undefined,
349
- contextUser: UserInfo
350
- ): Promise<SearchKnowledgeResultItem[]> {
351
- try {
352
- const md = new Metadata();
353
- const ftsResult = await md.FullTextSearch({
354
- SearchText: query,
355
- EntityNames: filters?.EntityNames,
356
- MaxRowsPerEntity: Math.max(3, Math.ceil(topK / 10))
357
- }, contextUser);
358
-
359
- if (!ftsResult.Success) {
360
- LogError(`SearchKnowledge: FTS error: ${ftsResult.ErrorMessage}`);
361
- return [];
362
- }
363
-
364
- return ftsResult.Results.map(r => ({
365
- ID: `ft-${r.EntityName}-${r.RecordID}`,
217
+ private mapSearchResult(result: SearchEngineResult): SearchKnowledgeResult {
218
+ return {
219
+ Success: result.Success,
220
+ Results: result.Results.map((r: SearchEngineResultItem) => ({
221
+ ID: r.ID,
366
222
  EntityName: r.EntityName,
367
223
  RecordID: r.RecordID,
368
- SourceType: 'fulltext',
224
+ SourceType: r.SourceType,
225
+ ResultType: r.ResultType,
369
226
  Title: r.Title,
370
227
  Snippet: r.Snippet,
371
228
  Score: r.Score,
372
- ScoreBreakdown: { FullText: r.Score },
373
- Tags: [],
374
- MatchedAt: new Date()
375
- }));
376
- } catch (error) {
377
- LogError(`SearchKnowledge: Full-text search error: ${error}`);
378
- return [];
379
- }
380
- }
381
-
382
- /**
383
- * Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
384
- * Deduplicates by RecordID, preferring the higher-scored source.
385
- */
386
- private fuseResults(
387
- vectorResults: SearchKnowledgeResultItem[],
388
- fullTextResults: SearchKnowledgeResultItem[],
389
- topK: number
390
- ): SearchKnowledgeResultItem[] {
391
- if (vectorResults.length === 0 && fullTextResults.length === 0) {
392
- return [];
393
- }
394
-
395
- // If only one source has results, just return those
396
- if (fullTextResults.length === 0) return vectorResults.slice(0, topK);
397
- if (vectorResults.length === 0) return fullTextResults.slice(0, topK);
398
-
399
- // Build scored candidate lists for RRF
400
- const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
401
- ID: r.RecordID,
402
- Score: r.Score,
403
- Rank: i + 1
404
- }));
405
- const ftCandidates: ScoredCandidate[] = fullTextResults.map((r, i) => ({
406
- ID: r.RecordID,
407
- Score: r.Score,
408
- Rank: i + 1
409
- }));
410
-
411
- // Compute RRF scores
412
- const fused = ComputeRRF([vectorCandidates, ftCandidates]);
413
-
414
- // Map fused results back to full result items
415
- const resultMap = new Map<string, SearchKnowledgeResultItem>();
416
- for (const r of [...vectorResults, ...fullTextResults]) {
417
- if (!resultMap.has(r.RecordID)) {
418
- resultMap.set(r.RecordID, r);
419
- }
420
- }
421
-
422
- return fused.slice(0, topK).map(candidate => {
423
- const item = resultMap.get(candidate.ID);
424
- if (item) {
425
- item.Score = candidate.Score;
426
- return item;
427
- }
428
- // Shouldn't happen, but fallback
429
- return {
430
- ID: candidate.ID,
431
- EntityName: 'Unknown',
432
- RecordID: candidate.ID,
433
- SourceType: 'fused',
434
- Title: 'Unknown',
435
- Snippet: '',
436
- Score: candidate.Score,
437
- ScoreBreakdown: {},
438
- Tags: [],
439
- MatchedAt: new Date()
440
- };
441
- });
442
- }
443
-
444
- /** Build Pinecone metadata filter from input */
445
- private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
446
- if (!filters) return undefined;
447
- const conditions: object[] = [];
448
- if (filters.EntityNames?.length) {
449
- conditions.push({ Entity: { $in: filters.EntityNames } });
450
- }
451
- if (filters.SourceTypes?.length) {
452
- conditions.push({ SourceType: { $in: filters.SourceTypes } });
453
- }
454
- if (filters.Tags?.length) {
455
- conditions.push({ Tags: { $in: filters.Tags } });
456
- }
457
- if (conditions.length === 0) return undefined;
458
- if (conditions.length === 1) return conditions[0];
459
- return { $and: conditions };
460
- }
461
-
462
- /** Convert Pinecone matches to SearchKnowledgeResultItem[] using enriched metadata */
463
- private convertMatches(
464
- matches: Array<{ id: string; score?: number; metadata?: Record<string, unknown> }>,
465
- indexName: string
466
- ): SearchKnowledgeResultItem[] {
467
- return matches.map(match => {
468
- const meta = match.metadata ?? {};
469
- const entityName = (meta['Entity'] as string) ?? 'Unknown';
470
- const recordID = (meta['RecordID'] as string) ?? match.id;
471
-
472
- // Extract display fields from enriched metadata
473
- const title = this.extractDisplayTitle(meta, entityName);
474
- const snippet = this.extractDisplaySnippet(meta, indexName, match.score);
475
- const entityIcon = (meta['EntityIcon'] as string) || undefined;
476
- const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
477
-
478
- return {
479
- ID: match.id,
480
- EntityName: entityName,
481
- RecordID: recordID,
482
- SourceType: 'vector',
483
- Title: title,
484
- Snippet: snippet,
485
- Score: match.score ?? 0,
486
- ScoreBreakdown: { Vector: match.score ?? 0 },
487
- Tags: [],
488
- EntityIcon: entityIcon,
489
- RecordName: title,
490
- MatchedAt: updatedAt,
491
- };
492
- });
493
- }
494
-
495
- /** Extract the best display title from vector metadata */
496
- private extractDisplayTitle(meta: Record<string, unknown>, fallbackEntity: string): string {
497
- // Check common name fields stored in metadata
498
- const nameFields = ['Name', 'Title', 'Subject', 'Label', 'DisplayName'];
499
- for (const field of nameFields) {
500
- if (meta[field] && typeof meta[field] === 'string') {
501
- return meta[field] as string;
502
- }
503
- }
504
- return `${fallbackEntity} Record`;
505
- }
506
-
507
- /** Extract the best display snippet from vector metadata */
508
- private extractDisplaySnippet(meta: Record<string, unknown>, indexName: string, score?: number): string {
509
- // Check common description fields stored in metadata
510
- const descFields = ['Description', 'Summary', 'Body', 'Content', 'Text', 'Notes'];
511
- for (const field of descFields) {
512
- if (meta[field] && typeof meta[field] === 'string') {
513
- const val = meta[field] as string;
514
- return val.length > 200 ? val.substring(0, 200) + '...' : val;
515
- }
516
- }
517
-
518
- // Build a snippet from other metadata fields (exclude system fields)
519
- const skipFields = new Set(['RecordID', 'Entity', 'TemplateID', 'EntityIcon', '__mj_UpdatedAt']);
520
- const parts: string[] = [];
521
- for (const [key, val] of Object.entries(meta)) {
522
- if (skipFields.has(key) || val == null) continue;
523
- const strVal = String(val);
524
- if (strVal.length > 0 && strVal.length < 100) {
525
- parts.push(`${key}: ${strVal}`);
526
- }
527
- if (parts.length >= 3) break;
528
- }
529
- if (parts.length > 0) return parts.join(' · ');
530
-
531
- return `Matched from index "${indexName}" with score ${(score ?? 0).toFixed(4)}`;
532
- }
533
-
534
- /** Remove duplicate results by RecordID, keeping the highest-scored entry */
535
- private deduplicateResults(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
536
- const seen = new Map<string, SearchKnowledgeResultItem>();
537
- for (const result of results) {
538
- const key = `${result.EntityName}::${result.RecordID}`;
539
- const existing = seen.get(key);
540
- if (!existing || result.Score > existing.Score) {
541
- seen.set(key, result);
542
- }
543
- }
544
- return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
545
- }
546
-
547
- /** Enrich results with entity icons and record names */
548
- private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
549
- const md = new Metadata();
550
-
551
- // Add entity icons for results that don't already have them from metadata
552
- for (const result of results) {
553
- if (!result.EntityIcon) {
554
- const entity = md.Entities.find(e => e.Name === result.EntityName);
555
- if (entity?.Icon) {
556
- result.EntityIcon = entity.Icon;
557
- }
558
- }
559
- }
560
-
561
- // Only resolve record names for results that don't already have them
562
- // (vector results from enriched metadata should already have names)
563
- const needsNameResolution = results.filter(r =>
564
- !r.RecordName || r.RecordName === `${r.EntityName} Record`
565
- );
566
-
567
- if (needsNameResolution.length === 0) return;
568
-
569
- try {
570
- const indexedResults: { index: number; input: EntityRecordNameInput }[] = [];
571
- for (const r of needsNameResolution) {
572
- const resultIndex = results.indexOf(r);
573
- const entity = md.Entities.find(e => e.Name === r.EntityName);
574
- if (!entity) continue;
575
-
576
- const key = new CompositeKey();
577
- key.LoadFromURLSegment(entity, r.RecordID);
578
-
579
- const input = new EntityRecordNameInput();
580
- input.EntityName = r.EntityName;
581
- input.CompositeKey = key;
582
- indexedResults.push({ index: resultIndex, input });
583
- }
584
-
585
- if (indexedResults.length > 0) {
586
- const names = await md.GetEntityRecordNames(
587
- indexedResults.map(ir => ir.input),
588
- contextUser
589
- );
590
- for (let i = 0; i < names.length; i++) {
591
- if (names[i].RecordName) {
592
- const resultIndex = indexedResults[i].index;
593
- results[resultIndex].RecordName = names[i].RecordName;
594
- results[resultIndex].Title = names[i].RecordName;
595
- }
596
- }
597
- }
598
- } catch (error) {
599
- LogError(`SearchKnowledge: Error resolving record names: ${error}`);
600
- // Non-fatal — results still usable without names
601
- }
229
+ ScoreBreakdown: r.ScoreBreakdown as SearchScoreBreakdown,
230
+ Tags: r.Tags || [],
231
+ EntityIcon: r.EntityIcon,
232
+ RecordName: r.RecordName,
233
+ MatchedAt: r.MatchedAt,
234
+ RawMetadata: r.RawMetadata,
235
+ ProviderId: r.ProviderId,
236
+ ProviderLabel: r.ProviderLabel,
237
+ ProviderIcon: r.ProviderIcon,
238
+ })),
239
+ TotalCount: result.TotalCount,
240
+ ElapsedMs: result.ElapsedMs,
241
+ SourceCounts: {
242
+ Vector: result.SourceCounts.Vector,
243
+ FullText: result.SourceCounts.FullText,
244
+ Entity: result.SourceCounts.Entity,
245
+ Storage: result.SourceCounts.Storage,
246
+ },
247
+ Providers: (result.Providers || []).map((p: SearchProviderInfo) => ({
248
+ ID: p.ID,
249
+ Name: p.Name,
250
+ DisplayName: p.DisplayName,
251
+ Icon: p.Icon,
252
+ SourceType: p.SourceType,
253
+ Priority: p.Priority,
254
+ })),
255
+ ErrorMessage: result.ErrorMessage,
256
+ };
602
257
  }
603
258
 
604
259
  private errorResult(message: string, startTime: number): SearchKnowledgeResult {
@@ -607,8 +262,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
607
262
  Results: [],
608
263
  TotalCount: 0,
609
264
  ElapsedMs: Date.now() - startTime,
610
- SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },
611
- ErrorMessage: message,
265
+ SourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
266
+ Providers: [],
267
+ ErrorMessage: message
612
268
  };
613
269
  }
614
270
  }