@memberjunction/server 5.22.0 → 5.24.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 (45) hide show
  1. package/README.md +35 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +11 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/generated/generated.d.ts +610 -4
  6. package/dist/generated/generated.d.ts.map +1 -1
  7. package/dist/generated/generated.js +17333 -13889
  8. package/dist/generated/generated.js.map +1 -1
  9. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  10. package/dist/generic/RunViewResolver.js.map +1 -1
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/resolvers/AutotagPipelineResolver.d.ts +30 -0
  16. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -0
  17. package/dist/resolvers/AutotagPipelineResolver.js +231 -0
  18. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -0
  19. package/dist/resolvers/ClientToolRequestResolver.d.ts +43 -0
  20. package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -0
  21. package/dist/resolvers/ClientToolRequestResolver.js +161 -0
  22. package/dist/resolvers/ClientToolRequestResolver.js.map +1 -0
  23. package/dist/resolvers/FetchEntityVectorsResolver.d.ts +29 -0
  24. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -0
  25. package/dist/resolvers/FetchEntityVectorsResolver.js +222 -0
  26. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -0
  27. package/dist/resolvers/RunAIAgentResolver.d.ts +21 -0
  28. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  29. package/dist/resolvers/RunAIAgentResolver.js +75 -33
  30. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  31. package/dist/resolvers/SearchKnowledgeResolver.d.ts +42 -1
  32. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  33. package/dist/resolvers/SearchKnowledgeResolver.js +239 -13
  34. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  35. package/package.json +63 -63
  36. package/src/__tests__/search-knowledge-tags.test.ts +415 -0
  37. package/src/config.ts +11 -0
  38. package/src/generated/generated.ts +2373 -7
  39. package/src/generic/RunViewResolver.ts +1 -0
  40. package/src/index.ts +10 -0
  41. package/src/resolvers/AutotagPipelineResolver.ts +235 -0
  42. package/src/resolvers/ClientToolRequestResolver.ts +128 -0
  43. package/src/resolvers/FetchEntityVectorsResolver.ts +238 -0
  44. package/src/resolvers/RunAIAgentResolver.ts +97 -56
  45. package/src/resolvers/SearchKnowledgeResolver.ts +270 -13
@@ -361,7 +361,9 @@ export class RunAIAgentResolver extends ResolverBase {
361
361
  createArtifacts: boolean = false,
362
362
  createNotification: boolean = false,
363
363
  sourceArtifactId?: string,
364
- sourceArtifactVersionId?: string
364
+ sourceArtifactVersionId?: string,
365
+ /** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
366
+ conversationId?: string
365
367
  ): Promise<AIAgentRunResult> {
366
368
  const startTime = Date.now();
367
369
 
@@ -406,6 +408,7 @@ export class RunAIAgentResolver extends ResolverBase {
406
408
  conversationMessages: parsedMessages,
407
409
  payload: payload ? SafeJSONParse(payload) : undefined,
408
410
  contextUser: currentUser,
411
+ sessionID: sessionId,
409
412
  onProgress: this.createProgressCallback(pubSub, sessionId, userPayload, agentRunRef),
410
413
  onStreaming: this.createStreamingCallback(pubSub, sessionId, userPayload, agentRunRef),
411
414
  lastRunId: lastRunId,
@@ -417,6 +420,7 @@ export class RunAIAgentResolver extends ResolverBase {
417
420
  }
418
421
  }, {
419
422
  conversationDetailId: conversationDetailId, // Use existing if provided
423
+ conversationId: conversationId, // LATENCY OPT #2: pre-resolved to skip redundant load in AgentRunner
420
424
  userMessage: userMessage, // Provide user message when conversationDetailId not provided
421
425
  createArtifacts: createArtifacts || false,
422
426
  sourceArtifactId: sourceArtifactId
@@ -434,37 +438,53 @@ export class RunAIAgentResolver extends ResolverBase {
434
438
 
435
439
  const executionTime = Date.now() - startTime;
436
440
 
437
- // Sync feedback request if this is a continuation run (user responded via conversation)
441
+ // LATENCY OPTIMIZATION (Opt #6): These three post-execution operations are independent
442
+ // of each other — none reads the output of another. Previously they ran sequentially,
443
+ // adding their latencies together (~50ms total). Now they run in parallel via Promise.all,
444
+ // so we only pay the cost of the slowest one.
445
+ //
446
+ // 1. syncFeedbackRequestFromConversation — links a prior Chat-step feedback request to
447
+ // the new agent run so the conversation thread stays coherent.
448
+ // 2. sendFeedbackRequestNotification — sends an in-app/email/SMS notification when the
449
+ // agent paused for human input (Chat step).
450
+ // 3. createCompletionNotification — sends an in-app/email/SMS notification that the
451
+ // agent finished and created an artifact.
452
+ const postExecutionOps: Promise<void>[] = [];
453
+
438
454
  if (lastRunId && result.agentRun?.ID) {
439
- await this.syncFeedbackRequestFromConversation(
440
- lastRunId,
441
- result.agentRun.ID,
442
- userMessage,
443
- currentUser
455
+ postExecutionOps.push(
456
+ this.syncFeedbackRequestFromConversation(lastRunId, result.agentRun.ID, userMessage, currentUser)
444
457
  );
445
458
  }
446
459
 
447
- // Send notification if agent created a feedback request (Chat step)
448
460
  if (result.feedbackRequestId) {
449
- await this.sendFeedbackRequestNotification(result, currentUser, pubSub, userPayload);
461
+ postExecutionOps.push(
462
+ this.sendFeedbackRequestNotification(result, currentUser, pubSub, userPayload)
463
+ );
450
464
  }
451
465
 
452
- // Create notification if enabled and artifact was created successfully
453
466
  if (createNotification && result.success && artifactInfo && artifactInfo.artifactId && artifactInfo.versionId && artifactInfo.versionNumber) {
454
- await this.createCompletionNotification(
455
- result.agentRun,
456
- {
457
- artifactId: artifactInfo.artifactId,
458
- versionId: artifactInfo.versionId,
459
- versionNumber: artifactInfo.versionNumber
460
- },
461
- finalConversationDetailId,
462
- currentUser,
463
- pubSub,
464
- userPayload
467
+ postExecutionOps.push(
468
+ this.createCompletionNotification(
469
+ result.agentRun,
470
+ {
471
+ artifactId: artifactInfo.artifactId,
472
+ versionId: artifactInfo.versionId,
473
+ versionNumber: artifactInfo.versionNumber
474
+ },
475
+ conversationResult.conversationId,
476
+ finalConversationDetailId,
477
+ currentUser,
478
+ pubSub,
479
+ userPayload
480
+ )
465
481
  );
466
482
  }
467
483
 
484
+ if (postExecutionOps.length > 0) {
485
+ await Promise.all(postExecutionOps);
486
+ }
487
+
468
488
  // Create sanitized payload for JSON serialization
469
489
  const sanitizedResult = this.sanitizeAgentResult(result);
470
490
  const returnResult = JSON.stringify(sanitizedResult);
@@ -679,34 +699,31 @@ export class RunAIAgentResolver extends ResolverBase {
679
699
  * Create a user notification for agent completion with artifact
680
700
  * Notification includes navigation link back to the conversation
681
701
  */
702
+ /**
703
+ * LATENCY OPTIMIZATION (Opt #2): Now accepts conversationId directly instead of
704
+ * conversationDetailId. Previously this method loaded a ConversationDetail entity
705
+ * from the DB solely to extract its ConversationID field for building a URL — a
706
+ * redundant ~50ms DB round-trip since the caller already resolved conversationId
707
+ * when loading conversation history.
708
+ */
682
709
  private async createCompletionNotification(
683
710
  agentRun: MJAIAgentRunEntityExtended,
684
711
  artifactInfo: { artifactId: string; versionId: string; versionNumber: number },
712
+ conversationId: string,
685
713
  conversationDetailId: string,
686
714
  contextUser: UserInfo,
687
715
  pubSub: PubSubEngine,
688
716
  userPayload: UserPayload
689
717
  ): Promise<void> {
690
718
  try {
691
- const md = new Metadata();
692
-
693
719
  // Get agent info for notification message
694
720
  await AIEngine.Instance.Config(false, contextUser);
695
721
  const agent = AIEngine.Instance.Agents.find(a => UUIDsEqual(a.ID, agentRun.AgentID));
696
722
  const agentName = agent?.Name || 'Agent';
697
723
 
698
- // Load conversation detail to get conversation info
699
- const detail = await md.GetEntityObject<MJConversationDetailEntity>(
700
- 'MJ: Conversation Details',
701
- contextUser
702
- );
703
- if (!(await detail.Load(conversationDetailId))) {
704
- throw new Error(`Failed to load conversation detail ${conversationDetailId}`);
705
- }
706
-
707
724
  // Build conversation URL for email/SMS templates
708
725
  const baseUrl = process.env.APP_BASE_URL || 'http://localhost:4201';
709
- const conversationUrl = `${baseUrl}/conversations/${detail.ConversationID}?artifact=${artifactInfo.artifactId}`;
726
+ const conversationUrl = `${baseUrl}/conversations/${conversationId}?artifact=${artifactInfo.artifactId}`;
710
727
 
711
728
  // Craft message based on versioning
712
729
  const message = artifactInfo.versionNumber > 1
@@ -723,7 +740,7 @@ export class RunAIAgentResolver extends ResolverBase {
723
740
  message: message,
724
741
  resourceConfiguration: {
725
742
  type: 'conversation',
726
- conversationId: detail.ConversationID,
743
+ conversationId: conversationId,
727
744
  messageId: conversationDetailId,
728
745
  artifactId: artifactInfo.artifactId,
729
746
  versionId: artifactInfo.versionId,
@@ -753,7 +770,8 @@ export class RunAIAgentResolver extends ResolverBase {
753
770
  notificationId: result.inAppNotificationId,
754
771
  action: 'create',
755
772
  title: `${agentName} completed your request`,
756
- message: message
773
+ message: message,
774
+ conversationId: conversationId
757
775
  })
758
776
  });
759
777
 
@@ -916,9 +934,24 @@ export class RunAIAgentResolver extends ResolverBase {
916
934
  }
917
935
 
918
936
  try {
937
+ // LATENCY OPTIMIZATION (Opt #2 + #3): Load ConversationDetail once here to extract
938
+ // conversationId, then pass it downstream. Previously this record was loaded multiple
939
+ // times: once in loadConversationHistoryWithAttachments (just to get conversationId),
940
+ // once in AgentRunner (same reason), and once in createCompletionNotification. Now we
941
+ // load it a single time and thread conversationId through the call chain.
942
+ const md = new Metadata();
943
+ const currentDetail = await md.GetEntityObject<MJConversationDetailEntity>(
944
+ 'MJ: Conversation Details',
945
+ currentUser
946
+ );
947
+ if (!await currentDetail.Load(conversationDetailId)) {
948
+ throw new Error(`Conversation detail ${conversationDetailId} not found`);
949
+ }
950
+ const conversationId = currentDetail.ConversationID;
951
+
919
952
  // Load conversation history with attachments from DB
920
953
  const messages = await this.loadConversationHistoryWithAttachments(
921
- conversationDetailId,
954
+ conversationId,
922
955
  currentUser,
923
956
  maxHistoryMessages || 20
924
957
  );
@@ -933,7 +966,7 @@ export class RunAIAgentResolver extends ResolverBase {
933
966
  p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
934
967
  data, payload, lastRunId, autoPopulateLastRunPayload, configurationId,
935
968
  conversationDetailId, createArtifacts || false, createNotification || false,
936
- sourceArtifactId, sourceArtifactVersionId
969
+ sourceArtifactId, sourceArtifactVersionId, conversationId
937
970
  );
938
971
 
939
972
  LogStatus(`🔥 Fire-and-forget: Agent ${agentId} execution started in background for session ${sessionId}`);
@@ -963,7 +996,8 @@ export class RunAIAgentResolver extends ResolverBase {
963
996
  createArtifacts || false,
964
997
  createNotification || false,
965
998
  sourceArtifactId,
966
- sourceArtifactVersionId
999
+ sourceArtifactVersionId,
1000
+ conversationId // LATENCY OPT #2: pass pre-resolved conversationId
967
1001
  );
968
1002
  } catch (error) {
969
1003
  const errorMessage = (error as Error).message || 'Unknown error loading conversation history';
@@ -1176,14 +1210,16 @@ export class RunAIAgentResolver extends ResolverBase {
1176
1210
  createArtifacts: boolean = false,
1177
1211
  createNotification: boolean = false,
1178
1212
  sourceArtifactId?: string,
1179
- sourceArtifactVersionId?: string
1213
+ sourceArtifactVersionId?: string,
1214
+ /** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
1215
+ conversationId?: string
1180
1216
  ): void {
1181
1217
  // Execute in background - errors are handled within, not propagated
1182
1218
  this.executeAIAgent(
1183
1219
  p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
1184
1220
  data, payload, undefined, lastRunId, autoPopulateLastRunPayload,
1185
1221
  configurationId, conversationDetailId, createArtifacts, createNotification,
1186
- sourceArtifactId, sourceArtifactVersionId
1222
+ sourceArtifactId, sourceArtifactVersionId, conversationId
1187
1223
  ).catch((error: unknown) => {
1188
1224
  // Background execution failed unexpectedly (executeAIAgent has its own try-catch,
1189
1225
  // so this would only fire for truly unexpected errors).
@@ -1208,34 +1244,39 @@ export class RunAIAgentResolver extends ResolverBase {
1208
1244
  /**
1209
1245
  * Load conversation history with attachments from database.
1210
1246
  * Builds ChatMessage[] with multimodal content blocks for attachments.
1247
+ *
1248
+ * LATENCY OPTIMIZATIONS (plans/agent-latency-optimization.md — Opts #3 and #8):
1249
+ *
1250
+ * Opt #3: This method now accepts conversationId directly instead of conversationDetailId.
1251
+ * Previously it loaded a ConversationDetail entity object just to extract its ConversationID
1252
+ * field — a redundant DB round-trip (~40ms) since the caller already has this information.
1253
+ * The caller (RunAIAgentFromConversationDetail) now loads the ConversationDetail once and
1254
+ * passes conversationId down.
1255
+ *
1256
+ * Opt #8: Switched from ResultType 'entity_object' to 'simple' with explicit Fields.
1257
+ * The history query only needs ID, Role, and Message from each ConversationDetail record.
1258
+ * Using 'entity_object' created full BaseEntity instances with getters/setters, dirty tracking,
1259
+ * and validation — none of which are needed for read-only history assembly. The 'simple' result
1260
+ * type returns plain JS objects, reducing per-record overhead (~30ms total savings).
1211
1261
  */
1212
1262
  private async loadConversationHistoryWithAttachments(
1213
- conversationDetailId: string,
1263
+ conversationId: string,
1214
1264
  contextUser: UserInfo,
1215
1265
  maxMessages: number
1216
1266
  ): Promise<ChatMessage[]> {
1217
- const md = new Metadata();
1218
1267
  const rv = new RunView();
1219
1268
  const attachmentService = getAttachmentService();
1220
1269
 
1221
- // Load the current conversation detail to get the conversation ID
1222
- const currentDetail = await md.GetEntityObject<MJConversationDetailEntity>(
1223
- 'MJ: Conversation Details',
1224
- contextUser
1225
- );
1226
- if (!await currentDetail.Load(conversationDetailId)) {
1227
- throw new Error(`Conversation detail ${conversationDetailId} not found`);
1228
- }
1229
-
1230
- const conversationId = currentDetail.ConversationID;
1231
-
1232
- // Load recent conversation details (messages) for this conversation
1233
- const detailsResult = await rv.RunView<MJConversationDetailEntity>({
1270
+ // Load recent conversation details (messages) for this conversation.
1271
+ // Only fetch the three fields we actually use — ID for attachment lookups,
1272
+ // Role for message routing, Message for content.
1273
+ const detailsResult = await rv.RunView<{ ID: string; Role: string; Message: string }>({
1234
1274
  EntityName: 'MJ: Conversation Details',
1235
1275
  ExtraFilter: `ConversationID='${conversationId}'`,
1236
1276
  OrderBy: '__mj_CreatedAt DESC',
1237
1277
  MaxRows: maxMessages,
1238
- ResultType: 'entity_object'
1278
+ Fields: ['ID', 'Role', 'Message'],
1279
+ ResultType: 'simple'
1239
1280
  }, contextUser);
1240
1281
 
1241
1282
  if (!detailsResult.Success || !detailsResult.Results) {
@@ -1,7 +1,7 @@
1
1
  import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Float, InputType } from 'type-graphql';
2
2
  import { AppContext } from '../types.js';
3
3
  import { LogError, LogStatus, Metadata, RunView, UserInfo, ComputeRRF, ScoredCandidate, EntityRecordNameInput, CompositeKey } from '@memberjunction/core';
4
- import { MJVectorIndexEntity, MJVectorDatabaseEntity } from '@memberjunction/core-entities';
4
+ import { MJVectorIndexEntity, MJVectorDatabaseEntity, KnowledgeHubMetadataEngine } from '@memberjunction/core-entities';
5
5
  import { ResolverBase } from '../generic/ResolverBase.js';
6
6
  import { AIEngine } from '@memberjunction/aiengine';
7
7
  import { BaseEmbeddings, GetAIAPIKey } from '@memberjunction/ai';
@@ -59,6 +59,10 @@ export class SearchKnowledgeResultItem {
59
59
 
60
60
  @Field()
61
61
  MatchedAt: Date;
62
+
63
+ /** Raw vector metadata as JSON string — contains all entity fields stored in the vector DB */
64
+ @Field({ nullable: true })
65
+ RawMetadata?: string;
62
66
  }
63
67
 
64
68
  @ObjectType()
@@ -149,11 +153,16 @@ export class SearchKnowledgeResolver extends ResolverBase {
149
153
  const fusedResults = this.fuseResults(vectorResults, fullTextResults, topK);
150
154
  const dedupedResults = this.deduplicateResults(fusedResults);
151
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
+
152
161
  // Apply minimum score threshold (post-RRF, so fusion can surface cross-source matches first)
153
162
  const scoreThreshold = minScore ?? 0;
154
163
  const filteredResults = scoreThreshold > 0
155
- ? dedupedResults.filter(r => r.Score >= scoreThreshold)
156
- : dedupedResults;
164
+ ? withoutEntityContentItems.filter(r => r.Score >= scoreThreshold)
165
+ : withoutEntityContentItems;
157
166
  LogStatus(`SearchKnowledge: Fuse + dedup + threshold≥${Math.round(scoreThreshold * 100)}% (${dedupedResults.length} → ${filteredResults.length} results): ${Date.now() - t0}ms`);
158
167
 
159
168
  // Enrich with entity icons and record names
@@ -341,6 +350,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
341
350
  * Full-text search using the MJCore Metadata.FullTextSearch() method.
342
351
  * Delegates to the provider stack which uses database-native FTS
343
352
  * (SQL Server FREETEXT, PostgreSQL tsvector) via RunView + UserSearchString.
353
+ * When Tags filters are provided, post-filters results by checking tag associations.
344
354
  */
345
355
  private async searchFullText(
346
356
  query: string,
@@ -361,7 +371,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
361
371
  return [];
362
372
  }
363
373
 
364
- return ftsResult.Results.map(r => ({
374
+ const results: SearchKnowledgeResultItem[] = ftsResult.Results.map(r => ({
365
375
  ID: `ft-${r.EntityName}-${r.RecordID}`,
366
376
  EntityName: r.EntityName,
367
377
  RecordID: r.RecordID,
@@ -370,15 +380,163 @@ export class SearchKnowledgeResolver extends ResolverBase {
370
380
  Snippet: r.Snippet,
371
381
  Score: r.Score,
372
382
  ScoreBreakdown: { FullText: r.Score },
373
- Tags: [],
383
+ Tags: [] as string[],
374
384
  MatchedAt: new Date()
375
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;
376
396
  } catch (error) {
377
397
  LogError(`SearchKnowledge: Full-text search error: ${error}`);
378
398
  return [];
379
399
  }
380
400
  }
381
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
+
382
540
  /**
383
541
  * Fuse vector and full-text results using Reciprocal Rank Fusion (RRF).
384
542
  * Deduplicates by RecordID, preferring the higher-scored source.
@@ -392,9 +550,14 @@ export class SearchKnowledgeResolver extends ResolverBase {
392
550
  return [];
393
551
  }
394
552
 
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);
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
+ }
398
561
 
399
562
  // Build scored candidate lists for RRF
400
563
  const vectorCandidates: ScoredCandidate[] = vectorResults.map((r, i) => ({
@@ -441,6 +604,26 @@ export class SearchKnowledgeResolver extends ResolverBase {
441
604
  });
442
605
  }
443
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
+
444
627
  /** Build Pinecone metadata filter from input */
445
628
  private buildPineconeFilter(filters?: SearchFiltersInput): object | undefined {
446
629
  if (!filters) return undefined;
@@ -475,6 +658,9 @@ export class SearchKnowledgeResolver extends ResolverBase {
475
658
  const entityIcon = (meta['EntityIcon'] as string) || undefined;
476
659
  const updatedAt = meta['__mj_UpdatedAt'] ? new Date(meta['__mj_UpdatedAt'] as string) : new Date();
477
660
 
661
+ // Extract tags from vector metadata if stored during indexing
662
+ const metaTags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
663
+
478
664
  return {
479
665
  ID: match.id,
480
666
  EntityName: entityName,
@@ -484,19 +670,47 @@ export class SearchKnowledgeResolver extends ResolverBase {
484
670
  Snippet: snippet,
485
671
  Score: match.score ?? 0,
486
672
  ScoreBreakdown: { Vector: match.score ?? 0 },
487
- Tags: [],
673
+ Tags: metaTags,
488
674
  EntityIcon: entityIcon,
489
675
  RecordName: title,
490
676
  MatchedAt: updatedAt,
677
+ RawMetadata: JSON.stringify(meta),
491
678
  };
492
679
  });
493
680
  }
494
681
 
495
- /** Extract the best display title from vector metadata */
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
+ */
496
687
  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) {
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) {
500
714
  if (meta[field] && typeof meta[field] === 'string') {
501
715
  return meta[field] as string;
502
716
  }
@@ -544,6 +758,49 @@ export class SearchKnowledgeResolver extends ResolverBase {
544
758
  return Array.from(seen.values()).sort((a, b) => b.Score - a.Score);
545
759
  }
546
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
+
547
804
  /** Enrich results with entity icons and record names */
548
805
  private async enrichResults(results: SearchKnowledgeResultItem[], contextUser: UserInfo): Promise<void> {
549
806
  const md = new Metadata();