@memberjunction/server 5.24.0 → 5.26.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 (62) 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 +70 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +21 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/generated/generated.d.ts +498 -0
  10. package/dist/generated/generated.d.ts.map +1 -1
  11. package/dist/generated/generated.js +2755 -0
  12. package/dist/generated/generated.js.map +1 -1
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +18 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/resolvers/ArtifactFileResolver.d.ts +15 -0
  18. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -0
  19. package/dist/resolvers/ArtifactFileResolver.js +74 -0
  20. package/dist/resolvers/ArtifactFileResolver.js.map +1 -0
  21. package/dist/resolvers/AutotagPipelineResolver.d.ts +13 -0
  22. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  23. package/dist/resolvers/AutotagPipelineResolver.js +103 -3
  24. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  25. package/dist/resolvers/CacheStatsResolver.d.ts +31 -0
  26. package/dist/resolvers/CacheStatsResolver.d.ts.map +1 -0
  27. package/dist/resolvers/CacheStatsResolver.js +181 -0
  28. package/dist/resolvers/CacheStatsResolver.js.map +1 -0
  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 +13 -1
  37. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  38. package/dist/resolvers/RunAIAgentResolver.js +115 -20
  39. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  40. package/dist/resolvers/SearchKnowledgeResolver.d.ts +21 -80
  41. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  42. package/dist/resolvers/SearchKnowledgeResolver.js +129 -604
  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 +66 -63
  49. package/src/__tests__/search-knowledge-tags.test.ts +177 -337
  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 +24 -0
  53. package/src/generated/generated.ts +1902 -1
  54. package/src/index.ts +18 -2
  55. package/src/resolvers/ArtifactFileResolver.ts +71 -0
  56. package/src/resolvers/AutotagPipelineResolver.ts +118 -4
  57. package/src/resolvers/CacheStatsResolver.ts +142 -0
  58. package/src/resolvers/FileResolver.ts +12 -41
  59. package/src/resolvers/GeoResolver.ts +258 -0
  60. package/src/resolvers/RunAIAgentResolver.ts +137 -23
  61. package/src/resolvers/SearchKnowledgeResolver.ts +114 -715
  62. 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, KnowledgeHubMetadataEngine } 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()
@@ -63,6 +62,22 @@ export class SearchKnowledgeResultItem {
63
62
  /** Raw vector metadata as JSON string — contains all entity fields stored in the vector DB */
64
63
  @Field({ nullable: true })
65
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;
66
81
  }
67
82
 
68
83
  @ObjectType()
@@ -75,6 +90,30 @@ export class SearchSourceCounts {
75
90
 
76
91
  @Field()
77
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;
78
117
  }
79
118
 
80
119
  @ObjectType()
@@ -94,6 +133,9 @@ export class SearchKnowledgeResult {
94
133
  @Field(() => SearchSourceCounts)
95
134
  SourceCounts: SearchSourceCounts;
96
135
 
136
+ @Field(() => [SearchProviderInfoType])
137
+ Providers: SearchProviderInfoType[];
138
+
97
139
  @Field({ nullable: true })
98
140
  ErrorMessage?: string;
99
141
  }
@@ -110,7 +152,7 @@ export class SearchFiltersInput {
110
152
  Tags?: string[];
111
153
  }
112
154
 
113
- /* ───── Resolver ───── */
155
+ /* ───── Resolver (thin wrapper around SearchEngine) ───── */
114
156
 
115
157
  @Resolver()
116
158
  export class SearchKnowledgeResolver extends ResolverBase {
@@ -130,58 +172,18 @@ export class SearchKnowledgeResolver extends ResolverBase {
130
172
  return this.errorResult('Unable to determine current user', startTime);
131
173
  }
132
174
 
133
- if (!query.trim()) {
134
- return this.errorResult('Query cannot be empty', startTime);
135
- }
136
-
137
- const topK = maxResults ?? 20;
138
-
139
- let t0 = Date.now();
140
- await AIEngine.Instance.Config(false, currentUser);
141
- LogStatus(`SearchKnowledge: AIEngine.Config: ${Date.now() - t0}ms`);
142
-
143
- // Run vector search and full-text search in parallel
144
- t0 = Date.now();
145
- const [vectorResults, fullTextResults] = await Promise.all([
146
- this.searchAllVectorIndexes(query, topK, filters, currentUser),
147
- this.searchFullText(query, topK, filters, currentUser)
148
- ]);
149
- LogStatus(`SearchKnowledge: Vector(${vectorResults.length}) + FTS(${fullTextResults.length}): ${Date.now() - t0}ms`);
150
-
151
- // Fuse results with RRF if we have results from multiple sources
152
- t0 = Date.now();
153
- const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
154
- const dedupedResults = this.deduplicateResults(fusedResults);
155
-
156
- // Exclude Content Items that originated from Entity sources — those entities
157
- // are already searchable directly via their own vector embeddings, so showing
158
- // the Content Item shadow result is redundant.
159
- const withoutEntityContentItems = await this.excludeEntitySourcedContentItems(dedupedResults, currentUser);
160
-
161
- // Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
162
- const scoreThreshold = minScore ?? 0;
163
- const filteredResults = scoreThreshold > 0
164
- ? withoutEntityContentItems.filter(r => r.Score >= scoreThreshold)
165
- : withoutEntityContentItems;
166
- LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
167
-
168
- // Enrich with entity icons and record names
169
- t0 = Date.now();
170
- await this.enrichResults(filteredResults, currentUser);
171
- LogStatus(`SearchKnowledge: Enrich (icons + names): ${Date.now() - t0}ms`);
172
- LogStatus(`SearchKnowledge: Total: ${Date.now() - startTime}ms`);
173
-
174
- return {
175
- Success: true,
176
- Results: filteredResults,
177
- TotalCount: filteredResults.length,
178
- ElapsedMs: Date.now() - startTime,
179
- SourceCounts: {
180
- Vector: vectorResults.length,
181
- FullText: fullTextResults.length,
182
- Entity: 0
183
- },
184
- };
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);
185
187
  } catch (error) {
186
188
  const msg = error instanceof Error ? error.message : String(error);
187
189
  LogError(`SearchKnowledge mutation failed: ${msg}`);
@@ -189,673 +191,69 @@ export class SearchKnowledgeResolver extends ResolverBase {
189
191
  }
190
192
  }
191
193
 
192
- /**
193
- * Search ALL vector indexes. Groups indexes by embedding model, embeds the query
194
- * in parallel per model, then queries all indexes per model in parallel as each
195
- * embedding completes. Maximum concurrency at every level.
196
- */
197
- private async searchAllVectorIndexes(
198
- query: string,
199
- topK: number,
200
- filters: SearchFiltersInput | undefined,
201
- contextUser: UserInfo
202
- ): Promise<SearchKnowledgeResultItem[]> {
203
- const rv = new RunView();
204
-
205
- const indexResult = await rv.RunView<MJVectorIndexEntity>({
206
- EntityName: 'MJ: Vector Indexes',
207
- ResultType: 'entity_object'
208
- }, contextUser);
209
-
210
- if (!indexResult.Success || indexResult.Results.length === 0) {
211
- LogStatus('SearchKnowledge: No vector indexes configured');
212
- return [];
213
- }
214
-
215
- // Group indexes by EmbeddingModelID
216
- const indexesByModel = this.groupIndexesByModel(indexResult.Results);
217
- const pineFilter = this.buildPineconeFilter(filters);
218
-
219
- // For each model group: embed query + query all indexes — all in parallel
220
- const modelGroupPromises = Array.from(indexesByModel.entries()).map(
221
- ([embeddingModelID, indexes]) =>
222
- this.embedAndQueryGroup(query, embeddingModelID, indexes, topK, pineFilter, contextUser)
223
- );
224
-
225
- const groupResults = await Promise.all(modelGroupPromises);
226
- return groupResults.flat();
227
- }
228
-
229
- /** Group vector indexes by their EmbeddingModelID */
230
- private groupIndexesByModel(indexes: MJVectorIndexEntity[]): Map<string, MJVectorIndexEntity[]> {
231
- const groups = new Map<string, MJVectorIndexEntity[]>();
232
- for (const index of indexes) {
233
- const modelId = index.EmbeddingModelID;
234
- const existing = groups.get(modelId);
235
- if (existing) {
236
- existing.push(index);
237
- } else {
238
- groups.set(modelId, [index]);
239
- }
240
- }
241
- return groups;
242
- }
243
-
244
- /**
245
- * Embed query with one model, then immediately query all indexes that use that model.
246
- * The embedding and subsequent queries are chained so index queries fire as soon as
247
- * the embedding completes — without waiting for other models' embeddings.
248
- */
249
- private async embedAndQueryGroup(
250
- query: string,
251
- embeddingModelID: string,
252
- indexes: MJVectorIndexEntity[],
253
- topK: number,
254
- filter: object | undefined,
255
- contextUser: UserInfo
256
- ): 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();
257
201
  try {
258
- // Find the AI model for this embedding
259
- const model = AIEngine.Instance.Models.find(m => UUIDsEqual(m.ID, embeddingModelID));
260
- if (!model) {
261
- LogError(`SearchKnowledge: Embedding model ${embeddingModelID} not found`);
262
- return [];
263
- }
264
-
265
- // Create embedding instance
266
- const apiKey = GetAIAPIKey(model.DriverClass);
267
- const embedding = MJGlobal.Instance.ClassFactory.CreateInstance<BaseEmbeddings>(
268
- BaseEmbeddings, model.DriverClass, apiKey
269
- );
270
- if (!embedding) {
271
- LogError(`SearchKnowledge: Failed to create embedding for ${model.DriverClass}`);
272
- return [];
202
+ const currentUser = this.GetUserFromPayload(userPayload);
203
+ if (!currentUser) {
204
+ return this.errorResult('Unable to determine current user', startTime);
273
205
  }
274
206
 
275
- // Embed the query with this model
276
- const embedResult = await embedding.EmbedText({ text: query, model: '' });
277
- if (!embedResult?.vector?.length) {
278
- LogError(`SearchKnowledge: Failed to embed with ${model.Name}`);
279
- return [];
280
- }
207
+ const result = await SearchEngine.Instance.PreviewSearch(query, maxResults, currentUser);
281
208
 
282
- // Query all indexes for this model in parallel
283
- const indexPromises = indexes.map(vectorIndex =>
284
- this.queryOneIndex(vectorIndex, embedResult.vector, topK, filter, contextUser)
285
- .catch(error => {
286
- LogError(`SearchKnowledge: Error querying index "${vectorIndex.Name}": ${error}`);
287
- return [] as SearchKnowledgeResultItem[];
288
- })
289
- );
290
-
291
- const indexResults = await Promise.all(indexPromises);
292
- return indexResults.flat();
209
+ return this.mapSearchResult(result);
293
210
  } catch (error) {
294
- LogError(`SearchKnowledge: Error in embedding group ${embeddingModelID}: ${error}`);
295
- return [];
296
- }
297
- }
298
-
299
- /**
300
- * Query a single vector index by looking up its VectorDatabase provider and passing the index name.
301
- */
302
- private async queryOneIndex(
303
- vectorIndex: MJVectorIndexEntity,
304
- queryVector: number[],
305
- topK: number,
306
- filter: object | undefined,
307
- contextUser: UserInfo
308
- ): Promise<SearchKnowledgeResultItem[]> {
309
- // Look up the vector database to get the ClassKey
310
- const rv = new RunView();
311
- const dbResult = await rv.RunView<MJVectorDatabaseEntity>({
312
- EntityName: 'MJ: Vector Databases',
313
- ExtraFilter: `ID='${vectorIndex.VectorDatabaseID}'`,
314
- ResultType: 'entity_object'
315
- }, contextUser);
316
-
317
- if (!dbResult.Success || dbResult.Results.length === 0) {
318
- LogError(`SearchKnowledge: VectorDatabase not found for index "${vectorIndex.Name}"`);
319
- return [];
320
- }
321
-
322
- const vectorDB = dbResult.Results[0];
323
- const apiKey = GetAIAPIKey(vectorDB.ClassKey);
324
- const vectorDBInstance = MJGlobal.Instance.ClassFactory.CreateInstance<VectorDBBase>(
325
- VectorDBBase, vectorDB.ClassKey, apiKey
326
- );
327
-
328
- if (!vectorDBInstance) {
329
- LogError(`SearchKnowledge: Failed to create VectorDB instance for "${vectorDB.ClassKey}"`);
330
- return [];
331
- }
332
-
333
- // Query with the specific index name
334
- const response: BaseResponse = await vectorDBInstance.QueryIndex({
335
- id: vectorIndex.Name,
336
- vector: queryVector,
337
- topK,
338
- includeMetadata: true,
339
- filter,
340
- });
341
-
342
- if (!response.success || !response.data?.matches) {
343
- return [];
211
+ const msg = error instanceof Error ? error.message : String(error);
212
+ LogError(`PreviewSearch mutation failed: ${msg}`);
213
+ return this.errorResult(msg, startTime);
344
214
  }
345
-
346
- return this.convertMatches(response.data.matches, vectorIndex.Name);
347
215
  }
348
216
 
349
- /**
350
- * Full-text search using the MJCore Metadata.FullTextSearch() method.
351
- * Delegates to the provider stack which uses database-native FTS
352
- * (SQL Server FREETEXT, PostgreSQL tsvector) via RunView + UserSearchString.
353
- * When Tags filters are provided, post-filters results by checking tag associations.
354
- */
355
- private async searchFullText(
356
- query: string,
357
- topK: number,
358
- filters: SearchFiltersInput | undefined,
359
- contextUser: UserInfo
360
- ): Promise<SearchKnowledgeResultItem[]> {
361
- try {
362
- const md = new Metadata();
363
- const ftsResult = await md.FullTextSearch({
364
- SearchText: query,
365
- EntityNames: filters?.EntityNames,
366
- MaxRowsPerEntity: Math.max(3, Math.ceil(topK / 10))
367
- }, contextUser);
368
-
369
- if (!ftsResult.Success) {
370
- LogError(`SearchKnowledge: FTS error: ${ftsResult.ErrorMessage}`);
371
- return [];
372
- }
373
-
374
- const results: SearchKnowledgeResultItem[] = ftsResult.Results.map(r => ({
375
- 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,
376
222
  EntityName: r.EntityName,
377
223
  RecordID: r.RecordID,
378
- SourceType: 'fulltext',
224
+ SourceType: r.SourceType,
225
+ ResultType: r.ResultType,
379
226
  Title: r.Title,
380
227
  Snippet: r.Snippet,
381
228
  Score: r.Score,
382
- ScoreBreakdown: { FullText: r.Score },
383
- Tags: [] as string[],
384
- MatchedAt: new Date()
385
- }));
386
-
387
- // Batch-load tags from TaggedItems/ContentItemTags for FTS results
388
- await this.enrichResultsWithTags(results, md, contextUser);
389
-
390
- // Apply tag filter: keep only results that have at least one matching tag
391
- if (filters?.Tags?.length) {
392
- return this.filterResultsByTags(results, filters.Tags);
393
- }
394
-
395
- return results;
396
- } catch (error) {
397
- LogError(`SearchKnowledge: Full-text search error: ${error}`);
398
- return [];
399
- }
400
- }
401
-
402
- /**
403
- * Filter results to only include those with at least one tag matching the specified tag list.
404
- * Uses case-insensitive comparison for robustness.
405
- */
406
- private filterResultsByTags(results: SearchKnowledgeResultItem[], requiredTags: string[]): SearchKnowledgeResultItem[] {
407
- const lowerTags = new Set(requiredTags.map(t => t.toLowerCase()));
408
- return results.filter(r =>
409
- r.Tags.some(t => lowerTags.has(t.toLowerCase()))
410
- );
411
- }
412
-
413
- /**
414
- * Enrich search results with tags from both the TaggedItems entity (for general entities)
415
- * and the ContentItemTag entity (for Content Items). Runs both queries in parallel
416
- * for maximum throughput, then merges the tag sets onto each result.
417
- */
418
- private async enrichResultsWithTags(
419
- results: SearchKnowledgeResultItem[],
420
- md: Metadata,
421
- contextUser: UserInfo
422
- ): Promise<void> {
423
- if (results.length === 0) return;
424
-
425
- try {
426
- // Separate results into Content Items vs everything else
427
- const contentItemResults = results.filter(r => r.EntityName === 'MJ: Content Items');
428
- const generalResults = results.filter(r => r.EntityName !== 'MJ: Content Items');
429
-
430
- // Run both tag lookups in parallel
431
- await Promise.all([
432
- this.loadTaggedItemTags(generalResults, md, contextUser),
433
- this.loadContentItemTags(contentItemResults, contextUser)
434
- ]);
435
- } catch (error) {
436
- LogError(`SearchKnowledge: Error enriching results with tags: ${error}`);
437
- // Non-fatal — results still usable without tags
438
- }
439
- }
440
-
441
- /**
442
- * Load tags from the TaggedItems entity for non-Content-Item results.
443
- * Queries by EntityID + RecordID pairs in a single batch.
444
- */
445
- private async loadTaggedItemTags(
446
- results: SearchKnowledgeResultItem[],
447
- md: Metadata,
448
- contextUser: UserInfo
449
- ): Promise<void> {
450
- if (results.length === 0) return;
451
-
452
- // Get entity IDs from metadata
453
- const entityIdMap = new Map<string, string>();
454
- for (const r of results) {
455
- if (!entityIdMap.has(r.EntityName)) {
456
- const entityInfo = md.Entities.find(e => e.Name === r.EntityName);
457
- if (entityInfo) {
458
- entityIdMap.set(r.EntityName, entityInfo.ID);
459
- }
460
- }
461
- }
462
-
463
- if (entityIdMap.size === 0) return;
464
-
465
- // Build filter for TaggedItems: OR across all entity+record pairs
466
- const conditions: string[] = [];
467
- for (const r of results) {
468
- const entityID = entityIdMap.get(r.EntityName);
469
- if (!entityID) continue;
470
- conditions.push(`(EntityID='${entityID}' AND RecordID='${r.RecordID}')`);
471
- }
472
-
473
- if (conditions.length === 0) return;
474
-
475
- const rv = new RunView();
476
- const tagResult = await rv.RunView<{ EntityID: string; RecordID: string; Tag: string }>({
477
- EntityName: 'MJ: Tagged Items',
478
- ExtraFilter: conditions.join(' OR '),
479
- Fields: ['EntityID', 'RecordID', 'Tag'],
480
- ResultType: 'simple'
481
- }, contextUser);
482
-
483
- if (!tagResult.Success) return;
484
-
485
- // Build a lookup: "entityID::recordID" -> tag names
486
- const tagMap = new Map<string, string[]>();
487
- for (const ti of tagResult.Results) {
488
- const key = `${ti.EntityID}::${ti.RecordID}`;
489
- const tags = tagMap.get(key) ?? [];
490
- tags.push(ti.Tag);
491
- tagMap.set(key, tags);
492
- }
493
-
494
- // Apply tags to results
495
- for (const r of results) {
496
- const entityID = entityIdMap.get(r.EntityName);
497
- if (!entityID) continue;
498
- const key = `${entityID}::${r.RecordID}`;
499
- r.Tags = tagMap.get(key) ?? [];
500
- }
501
- }
502
-
503
- /**
504
- * Load tags from the ContentItemTag entity for Content Item results.
505
- * Queries by ItemID (which maps to the result's RecordID) in a single batch.
506
- */
507
- private async loadContentItemTags(
508
- results: SearchKnowledgeResultItem[],
509
- contextUser: UserInfo
510
- ): Promise<void> {
511
- if (results.length === 0) return;
512
-
513
- const recordIDs = results.map(r => `'${r.RecordID}'`);
514
- const rv = new RunView();
515
- const tagResult = await rv.RunView<{ ItemID: string; Tag: string }>({
516
- EntityName: 'MJ: Content Item Tags',
517
- ExtraFilter: `ItemID IN (${recordIDs.join(',')})`,
518
- Fields: ['ItemID', 'Tag'],
519
- ResultType: 'simple'
520
- }, contextUser);
521
-
522
- if (!tagResult.Success) return;
523
-
524
- // Build a lookup: ItemID -> tag names
525
- const tagMap = new Map<string, string[]>();
526
- for (const ti of tagResult.Results) {
527
- const key = ti.ItemID;
528
- const tags = tagMap.get(key) ?? [];
529
- tags.push(ti.Tag);
530
- tagMap.set(key, tags);
531
- }
532
-
533
- // Apply tags to results (use case-insensitive UUID comparison)
534
- for (const r of results) {
535
- // Check both raw and lowercase keys since UUID casing may vary
536
- r.Tags = tagMap.get(r.RecordID) ?? tagMap.get(r.RecordID.toLowerCase()) ?? [];
537
- }
538
- }
539
-
540
- /**
541
- * Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
542
- * Deduplicates by RecordID, preferring the higher-scored source.
543
- */
544
- private fuseResults(
545
- vectorResults: SearchKnowledgeResultItem[],
546
- fullTextResults: SearchKnowledgeResultItem[],
547
- topK: number
548
- ): SearchKnowledgeResultItem[] {
549
- if (vectorResults.length === 0 && fullTextResults.length === 0) {
550
- return [];
551
- }
552
-
553
- // If only one source has results, normalize scores relative to the top result
554
- // so the best match shows ~90-95% instead of raw cosine similarity (~40-50%)
555
- if (fullTextResults.length === 0) {
556
- return this.normalizeScores(vectorResults.slice(0, topK));
557
- }
558
- if (vectorResults.length === 0) {
559
- return this.normalizeScores(fullTextResults.slice(0, topK));
560
- }
561
-
562
- // Build scored candidate lists for RRF
563
- const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
564
- ID: r.RecordID,
565
- Score: r.Score,
566
- Rank: i + 1
567
- }));
568
- const ftCandidates: ScoredCandidate[] = fullTextResults.map((r, i) => ({
569
- ID: r.RecordID,
570
- Score: r.Score,
571
- Rank: i + 1
572
- }));
573
-
574
- // Compute RRF scores
575
- const fused = ComputeRRF([vectorCandidates, ftCandidates]);
576
-
577
- // Map fused results back to full result items
578
- const resultMap = new Map<string, SearchKnowledgeResultItem>();
579
- for (const r of [...vectorResults, ...fullTextResults]) {
580
- if (!resultMap.has(r.RecordID)) {
581
- resultMap.set(r.RecordID, r);
582
- }
583
- }
584
-
585
- return fused.slice(0, topK).map(candidate => {
586
- const item = resultMap.get(candidate.ID);
587
- if (item) {
588
- item.Score = candidate.Score;
589
- return item;
590
- }
591
- // Shouldn't happen, but fallback
592
- return {
593
- ID: candidate.ID,
594
- EntityName: 'Unknown',
595
- RecordID: candidate.ID,
596
- SourceType: 'fused',
597
- Title: 'Unknown',
598
- Snippet: '',
599
- Score: candidate.Score,
600
- ScoreBreakdown: {},
601
- Tags: [],
602
- MatchedAt: new Date()
603
- };
604
- });
605
- }
606
-
607
- /**
608
- * Normalize scores when only one search source returned results.
609
- * Scales scores relative to the top result so the best match shows
610
- * ~90-95% instead of raw cosine similarity (~40-50%).
611
- * This prevents artificially low-looking scores when RRF isn't applied.
612
- */
613
- private normalizeScores(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
614
- if (results.length === 0) return results;
615
-
616
- const maxScore = results[0].Score; // Results are already sorted by score desc
617
- if (maxScore <= 0) return results;
618
-
619
- // Scale so the top result maps to ~0.95 and others proportionally
620
- const scaleFactor = 0.95 / maxScore;
621
- for (const r of results) {
622
- r.Score = Math.min(0.99, r.Score * scaleFactor);
623
- }
624
- return results;
625
- }
626
-
627
- /** Build Pinecone metadata filter from input */
628
- private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
629
- if (!filters) return undefined;
630
- const conditions: object[] = [];
631
- if (filters.EntityNames?.length) {
632
- conditions.push({ Entity: { $in: filters.EntityNames } });
633
- }
634
- if (filters.SourceTypes?.length) {
635
- conditions.push({ SourceType: { $in: filters.SourceTypes } });
636
- }
637
- if (filters.Tags?.length) {
638
- conditions.push({ Tags: { $in: filters.Tags } });
639
- }
640
- if (conditions.length === 0) return undefined;
641
- if (conditions.length === 1) return conditions[0];
642
- return { $and: conditions };
643
- }
644
-
645
- /** Convert Pinecone matches to SearchKnowledgeResultItem[] using enriched metadata */
646
- private convertMatches(
647
- matches: Array<{ id: string; score?: number; metadata?: Record<string, unknown> }>,
648
- indexName: string
649
- ): SearchKnowledgeResultItem[] {
650
- return matches.map(match => {
651
- const meta = match.metadata ?? {};
652
- const entityName = (meta['Entity'] as string) ?? 'Unknown';
653
- const recordID = (meta['RecordID'] as string) ?? match.id;
654
-
655
- // Extract display fields from enriched metadata
656
- const title = this.extractDisplayTitle(meta, entityName);
657
- const snippet = this.extractDisplaySnippet(meta, indexName, match.score);
658
- const entityIcon = (meta['EntityIcon'] as string) || undefined;
659
- const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
660
-
661
- // Extract tags from vector metadata if stored during indexing
662
- const metaTags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
663
-
664
- return {
665
- ID: match.id,
666
- EntityName: entityName,
667
- RecordID: recordID,
668
- SourceType: 'vector',
669
- Title: title,
670
- Snippet: snippet,
671
- Score: match.score ?? 0,
672
- ScoreBreakdown: { Vector: match.score ?? 0 },
673
- Tags: metaTags,
674
- EntityIcon: entityIcon,
675
- RecordName: title,
676
- MatchedAt: updatedAt,
677
- RawMetadata: JSON.stringify(meta),
678
- };
679
- });
680
- }
681
-
682
- /**
683
- * Extract the best display title from vector metadata using entity field metadata.
684
- * Combines all IsNameField fields in Sequence order (e.g., FirstName + LastName → "Sarah Chen").
685
- * Falls back to heuristic field name matching when entity metadata isn't available.
686
- */
687
- private extractDisplayTitle(meta: Record<string, unknown>, fallbackEntity: string): string {
688
- // 1. Use entity metadata to find IsNameField fields and combine them
689
- const entityName = meta['Entity'] as string | undefined;
690
- if (entityName) {
691
- const md = new Metadata();
692
- const entityInfo = md.Entities.find(e => e.Name === entityName);
693
- if (entityInfo) {
694
- const nameFields = entityInfo.Fields
695
- .filter(f => f.IsNameField)
696
- .sort((a, b) => (a.Sequence ?? 9999) - (b.Sequence ?? 9999));
697
- if (nameFields.length > 0) {
698
- const parts = nameFields
699
- .map(f => meta[f.Name])
700
- .filter(v => v != null && String(v).trim() !== '')
701
- .map(v => String(v));
702
- if (parts.length > 0) return parts.join(' ');
703
- }
704
- // Single NameField fallback
705
- if (entityInfo.NameField && meta[entityInfo.NameField.Name]) {
706
- return String(meta[entityInfo.NameField.Name]);
707
- }
708
- }
709
- }
710
-
711
- // 2. Heuristic fallbacks for common field names
712
- const heuristicFields = ['Name', 'Title', 'Subject', 'Label', 'DisplayName'];
713
- for (const field of heuristicFields) {
714
- if (meta[field] && typeof meta[field] === 'string') {
715
- return meta[field] as string;
716
- }
717
- }
718
- return `${fallbackEntity} Record`;
719
- }
720
-
721
- /** Extract the best display snippet from vector metadata */
722
- private extractDisplaySnippet(meta: Record<string, unknown>, indexName: string, score?: number): string {
723
- // Check common description fields stored in metadata
724
- const descFields = ['Description', 'Summary', 'Body', 'Content', 'Text', 'Notes'];
725
- for (const field of descFields) {
726
- if (meta[field] && typeof meta[field] === 'string') {
727
- const val = meta[field] as string;
728
- return val.length > 200 ? val.substring(0, 200) + '...' : val;
729
- }
730
- }
731
-
732
- // Build a snippet from other metadata fields (exclude system fields)
733
- const skipFields = new Set(['RecordID', 'Entity', 'TemplateID', 'EntityIcon', '__mj_UpdatedAt']);
734
- const parts: string[] = [];
735
- for (const [key, val] of Object.entries(meta)) {
736
- if (skipFields.has(key) || val == null) continue;
737
- const strVal = String(val);
738
- if (strVal.length > 0 && strVal.length < 100) {
739
- parts.push(`${key}: ${strVal}`);
740
- }
741
- if (parts.length >= 3) break;
742
- }
743
- if (parts.length > 0) return parts.join(' · ');
744
-
745
- return `Matched from index "${indexName}" with score ${(score ?? 0).toFixed(4)}`;
746
- }
747
-
748
- /** Remove duplicate results by RecordID, keeping the highest-scored entry */
749
- private deduplicateResults(results: SearchKnowledgeResultItem[]): SearchKnowledgeResultItem[] {
750
- const seen = new Map<string, SearchKnowledgeResultItem>();
751
- for (const result of results) {
752
- const key = `${result.EntityName}::${result.RecordID}`;
753
- const existing = seen.get(key);
754
- if (!existing || result.Score > existing.Score) {
755
- seen.set(key, result);
756
- }
757
- }
758
- return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
759
- }
760
-
761
- /**
762
- * Remove Content Item results that originated from Entity-type content sources.
763
- * These are redundant because the underlying entity records are already vectorized
764
- * and searchable directly — showing both creates duplicate results.
765
- */
766
- private async excludeEntitySourcedContentItems(
767
- results: SearchKnowledgeResultItem[],
768
- contextUser: UserInfo
769
- ): Promise<SearchKnowledgeResultItem[]> {
770
- const contentItemResults = results.filter(r => r.EntityName === 'MJ: Content Items');
771
- if (contentItemResults.length === 0) return results;
772
-
773
- // Use cached engine data — no RunView needed
774
- await KnowledgeHubMetadataEngine.Instance.Config(false, contextUser);
775
- const engine = KnowledgeHubMetadataEngine.Instance;
776
-
777
- // Find source type ID for "Entity"
778
- const entitySourceType = engine.ContentSourceTypes.find(st => st.Name === 'Entity');
779
- if (!entitySourceType) return results;
780
-
781
- // Collect all ContentSource IDs that are entity-type
782
- const entitySourceIDs = new Set(
783
- engine.ContentSources
784
- .filter(cs => UUIDsEqual(cs.ContentSourceTypeID, entitySourceType.ID))
785
- .map(cs => cs.ID.toLowerCase())
786
- );
787
- if (entitySourceIDs.size === 0) return results;
788
-
789
- // Filter using ContentSourceID from vector metadata — no DB lookup needed
790
- return results.filter(r => {
791
- if (r.EntityName !== 'MJ: Content Items') return true;
792
- if (!r.RawMetadata) return true;
793
- try {
794
- const meta = JSON.parse(r.RawMetadata) as Record<string, string>;
795
- const sourceID = meta.ContentSourceID;
796
- if (!sourceID) return true;
797
- return !entitySourceIDs.has(sourceID.toLowerCase());
798
- } catch {
799
- return true;
800
- }
801
- });
802
- }
803
-
804
- /** Enrich results with entity icons and record names */
805
- private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
806
- const md = new Metadata();
807
-
808
- // Add entity icons for results that don't already have them from metadata
809
- for (const result of results) {
810
- if (!result.EntityIcon) {
811
- const entity = md.Entities.find(e => e.Name === result.EntityName);
812
- if (entity?.Icon) {
813
- result.EntityIcon = entity.Icon;
814
- }
815
- }
816
- }
817
-
818
- // Only resolve record names for results that don't already have them
819
- // (vector results from enriched metadata should already have names)
820
- const needsNameResolution = results.filter(r =>
821
- !r.RecordName || r.RecordName === `${r.EntityName} Record`
822
- );
823
-
824
- if (needsNameResolution.length === 0) return;
825
-
826
- try {
827
- const indexedResults: { index: number; input: EntityRecordNameInput }[] = [];
828
- for (const r of needsNameResolution) {
829
- const resultIndex = results.indexOf(r);
830
- const entity = md.Entities.find(e => e.Name === r.EntityName);
831
- if (!entity) continue;
832
-
833
- const key = new CompositeKey();
834
- key.LoadFromURLSegment(entity, r.RecordID);
835
-
836
- const input = new EntityRecordNameInput();
837
- input.EntityName = r.EntityName;
838
- input.CompositeKey = key;
839
- indexedResults.push({ index: resultIndex, input });
840
- }
841
-
842
- if (indexedResults.length > 0) {
843
- const names = await md.GetEntityRecordNames(
844
- indexedResults.map(ir => ir.input),
845
- contextUser
846
- );
847
- for (let i = 0; i < names.length; i++) {
848
- if (names[i].RecordName) {
849
- const resultIndex = indexedResults[i].index;
850
- results[resultIndex].RecordName = names[i].RecordName;
851
- results[resultIndex].Title = names[i].RecordName;
852
- }
853
- }
854
- }
855
- } catch (error) {
856
- LogError(`SearchKnowledge: Error resolving record names: ${error}`);
857
- // Non-fatal — results still usable without names
858
- }
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
+ };
859
257
  }
860
258
 
861
259
  private errorResult(message: string, startTime: number): SearchKnowledgeResult {
@@ -864,8 +262,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
864
262
  Results: [],
865
263
  TotalCount: 0,
866
264
  ElapsedMs: Date.now() - startTime,
867
- SourceCounts: { Vector: 0, FullText: 0, Entity: 0 },
868
- ErrorMessage: message,
265
+ SourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
266
+ Providers: [],
267
+ ErrorMessage: message
869
268
  };
870
269
  }
871
270
  }