@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.
- package/README.md +35 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +610 -4
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +17333 -13889
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/AutotagPipelineResolver.d.ts +30 -0
- package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -0
- package/dist/resolvers/AutotagPipelineResolver.js +231 -0
- package/dist/resolvers/AutotagPipelineResolver.js.map +1 -0
- package/dist/resolvers/ClientToolRequestResolver.d.ts +43 -0
- package/dist/resolvers/ClientToolRequestResolver.d.ts.map +1 -0
- package/dist/resolvers/ClientToolRequestResolver.js +161 -0
- package/dist/resolvers/ClientToolRequestResolver.js.map +1 -0
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +29 -0
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -0
- package/dist/resolvers/FetchEntityVectorsResolver.js +222 -0
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts +21 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +75 -33
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +42 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +239 -13
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/package.json +63 -63
- package/src/__tests__/search-knowledge-tags.test.ts +415 -0
- package/src/config.ts +11 -0
- package/src/generated/generated.ts +2373 -7
- package/src/generic/RunViewResolver.ts +1 -0
- package/src/index.ts +10 -0
- package/src/resolvers/AutotagPipelineResolver.ts +235 -0
- package/src/resolvers/ClientToolRequestResolver.ts +128 -0
- package/src/resolvers/FetchEntityVectorsResolver.ts +238 -0
- package/src/resolvers/RunAIAgentResolver.ts +97 -56
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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/${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
156
|
-
:
|
|
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
|
-
|
|
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,
|
|
396
|
-
|
|
397
|
-
if (
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
498
|
-
const
|
|
499
|
-
|
|
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();
|