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