@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
@@ -765,6 +765,7 @@ export class RunViewResolver extends ResolverBase {
765
765
  for (const [index, data] of rawData.entries()) {
766
766
  // EntityName is backfilled by RunViewsGeneric when ViewID/ViewName was used
767
767
  const entity = input[index].EntityName ? provider.Entities.find((e) => e.Name === input[index].EntityName) : null;
768
+
768
769
  const returnData: any[] = this.processRawData(data.Results, entity ? entity.ID : null, entity);
769
770
 
770
771
  results.push({
package/src/index.ts CHANGED
@@ -42,6 +42,7 @@ import { GetAPIKeyEngine } from '@memberjunction/api-keys';
42
42
  import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
43
43
  import { GenericDatabaseProvider } from '@memberjunction/generic-database-provider';
44
44
  import { PubSubManager } from './generic/PubSubManager.js';
45
+ import { ClientToolRequestManager } from '@memberjunction/ai-agents';
45
46
  import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
46
47
  import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
47
48
  import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
@@ -95,7 +96,10 @@ export * from './resolvers/RunAIPromptResolver.js';
95
96
  export * from './resolvers/RunAIAgentResolver.js';
96
97
  export * from './resolvers/VectorizeEntityResolver.js';
97
98
  export * from './resolvers/SearchKnowledgeResolver.js';
99
+ export * from './resolvers/FetchEntityVectorsResolver.js';
98
100
  export * from './resolvers/PipelineProgressResolver.js';
101
+ export * from './resolvers/ClientToolRequestResolver.js';
102
+ export * from './resolvers/AutotagPipelineResolver.js';
99
103
  export * from './resolvers/TaskResolver.js';
100
104
  export * from './generic/KeyValuePairInput.js';
101
105
  export * from './generic/KeyInputOutputTypes.js';
@@ -582,6 +586,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
582
586
  }
583
587
  PubSubManager.Instance.SetPubSubEngine(pubSub as unknown as PubSubEngine);
584
588
 
589
+ // Wire the ClientToolRequestManager so BaseAgent can publish client tool requests
590
+ // via the same PubSub infrastructure used for pipeline progress and cache invalidation.
591
+ ClientToolRequestManager.Instance.SetPublishFunction(
592
+ (topic: string, payload: Record<string, unknown>) => PubSubManager.Instance.Publish(topic, payload)
593
+ );
594
+
585
595
  // Global listener: broadcast CACHE_INVALIDATION to all browser clients whenever
586
596
  // ANY BaseEntity save/delete occurs on this server — regardless of whether it
587
597
  // originated from a GraphQL mutation or internal server-side code (agents, actions,
@@ -0,0 +1,235 @@
1
+ import { Resolver, Mutation, Ctx, Arg, ObjectType, Field } from 'type-graphql';
2
+ import { AppContext } from '../types.js';
3
+ import { LogError, LogStatus, Metadata } from '@memberjunction/core';
4
+ import { MJContentProcessRunEntity } from '@memberjunction/core-entities';
5
+ import { ResolverBase } from '../generic/ResolverBase.js';
6
+ import { ActionEngineServer } from '@memberjunction/actions';
7
+ import { PubSubManager } from '../generic/PubSubManager.js';
8
+ import { PipelineProgressNotification } from './PipelineProgressResolver.js';
9
+ import { v4 as uuidv4 } from 'uuid';
10
+
11
+ const PIPELINE_PROGRESS_TOPIC = 'PIPELINE_PROGRESS';
12
+
13
+ @ObjectType()
14
+ export class AutotagPipelineResult {
15
+ @Field()
16
+ Success: boolean;
17
+
18
+ @Field({ nullable: true })
19
+ Status?: string;
20
+
21
+ @Field({ nullable: true })
22
+ ErrorMessage?: string;
23
+
24
+ @Field({ nullable: true })
25
+ PipelineRunID?: string;
26
+ }
27
+
28
+ @Resolver()
29
+ export class AutotagPipelineResolver extends ResolverBase {
30
+ @Mutation(() => AutotagPipelineResult)
31
+ async RunAutotagPipeline(
32
+ @Arg('contentSourceIDs', () => [String], { nullable: true }) contentSourceIDs: string[] | undefined,
33
+ @Arg('forceReprocess', { nullable: true }) forceReprocess: boolean | undefined,
34
+ @Ctx() { userPayload }: AppContext = {} as AppContext
35
+ ): Promise<AutotagPipelineResult> {
36
+ try {
37
+ const currentUser = this.GetUserFromPayload(userPayload);
38
+ if (!currentUser) {
39
+ return { Success: false, Status: 'Error', ErrorMessage: 'Unable to determine current user' };
40
+ }
41
+
42
+ const pipelineRunID = uuidv4();
43
+ LogStatus(`RunAutotagPipeline: starting pipeline ${pipelineRunID}`);
44
+
45
+ // Fire-and-forget: start the pipeline in the background and return immediately
46
+ this.runPipelineInBackground(pipelineRunID, currentUser, contentSourceIDs, forceReprocess);
47
+
48
+ return {
49
+ Success: true,
50
+ Status: 'Started',
51
+ PipelineRunID: pipelineRunID,
52
+ };
53
+ } catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ LogError(`RunAutotagPipeline mutation failed: ${msg}`);
56
+ return {
57
+ Success: false,
58
+ Status: 'Error',
59
+ ErrorMessage: msg
60
+ };
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Runs the autotag + vectorize pipeline in the background, publishing progress
66
+ * updates via PubSub so the client can subscribe via PipelineProgress.
67
+ */
68
+ private async runPipelineInBackground(
69
+ pipelineRunID: string,
70
+ currentUser: import('@memberjunction/core').UserInfo,
71
+ contentSourceIDs?: string[],
72
+ forceReprocess?: boolean
73
+ ): Promise<void> {
74
+ const startTime = Date.now();
75
+ try {
76
+ this.publishProgress(pipelineRunID, 'autotag', 0, 0, startTime, 'Initializing pipeline...');
77
+
78
+ await ActionEngineServer.Instance.Config(false, currentUser);
79
+ const action = ActionEngineServer.Instance.Actions.find(
80
+ a => a.Name === 'Autotag and Vectorize Content'
81
+ );
82
+
83
+ if (!action) {
84
+ LogError(`RunAutotagPipeline: Action 'Autotag and Vectorize Content' not found`);
85
+ this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, 'Autotag action not found');
86
+ return;
87
+ }
88
+
89
+ // Stage: autotagging — provide a progress callback that publishes per-item updates
90
+ this.publishProgress(pipelineRunID, 'autotag', 0, 0, startTime, 'Running autotaggers...');
91
+
92
+ const progressCallback = (processed: number, total: number, currentItem?: string) => {
93
+ const pct = total > 0 ? Math.round((processed / total) * 80) : 0; // 0-80% for tagging
94
+ this.publishProgress(pipelineRunID, 'autotag', total, pct, startTime, currentItem || `${processed}/${total} items`);
95
+ };
96
+
97
+ // Build action params
98
+ const actionParams: Array<{ Name: string; Value: unknown; Type: 'Input' | 'Output' | 'Both' }> = [
99
+ { Name: 'Autotag', Value: 1, Type: 'Input' },
100
+ { Name: 'Vectorize', Value: 1, Type: 'Input' },
101
+ { Name: '__progressCallback', Value: progressCallback, Type: 'Input' }
102
+ ];
103
+ if (contentSourceIDs && contentSourceIDs.length > 0) {
104
+ actionParams.push({ Name: 'ContentSourceIDs', Value: contentSourceIDs, Type: 'Input' });
105
+ }
106
+ if (forceReprocess) {
107
+ actionParams.push({ Name: 'ForceReprocess', Value: 1, Type: 'Input' });
108
+ }
109
+
110
+ const result = await ActionEngineServer.Instance.RunAction({
111
+ Action: action,
112
+ ContextUser: currentUser,
113
+ Filters: [],
114
+ Params: actionParams
115
+ });
116
+
117
+ // Stage: vectorize complete
118
+ this.publishProgress(pipelineRunID, 'vectorize', 100, 90, startTime, 'Vectorizing content...');
119
+
120
+ if (result.Success) {
121
+ LogStatus(`RunAutotagPipeline: pipeline ${pipelineRunID} completed successfully`);
122
+ this.publishProgress(pipelineRunID, 'complete', 100, 100, startTime);
123
+ } else {
124
+ LogError(`RunAutotagPipeline: pipeline ${pipelineRunID} failed: ${result.Message}`);
125
+ this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, String(result.Message));
126
+ }
127
+ } catch (error) {
128
+ const msg = error instanceof Error ? error.message : String(error);
129
+ LogError(`RunAutotagPipeline pipeline ${pipelineRunID} failed: ${msg}`);
130
+ this.publishProgress(pipelineRunID, 'error', 0, 0, startTime, msg);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Pause a running classification pipeline by setting CancellationRequested on the process run.
136
+ * The engine checks this flag between batches and pauses gracefully.
137
+ */
138
+ @Mutation(() => AutotagPipelineResult)
139
+ async PauseClassificationPipeline(
140
+ @Arg('processRunID') processRunID: string,
141
+ @Ctx() { userPayload }: AppContext = {} as AppContext
142
+ ): Promise<AutotagPipelineResult> {
143
+ try {
144
+ const currentUser = this.GetUserFromPayload(userPayload);
145
+ if (!currentUser) {
146
+ return { Success: false, Status: 'Error', ErrorMessage: 'Unable to determine current user' };
147
+ }
148
+
149
+ const md = new Metadata();
150
+ const run = await md.GetEntityObject<MJContentProcessRunEntity>('MJ: Content Process Runs', currentUser);
151
+ const loaded = await run.Load(processRunID);
152
+ if (!loaded) {
153
+ return { Success: false, Status: 'Error', ErrorMessage: `Process run ${processRunID} not found` };
154
+ }
155
+
156
+ run.CancellationRequested = true;
157
+ await run.Save();
158
+
159
+ LogStatus(`PauseClassificationPipeline: Pause requested for run ${processRunID}`);
160
+ return { Success: true, Status: 'PauseRequested' };
161
+ } catch (error) {
162
+ const msg = error instanceof Error ? error.message : String(error);
163
+ return { Success: false, Status: 'Error', ErrorMessage: msg };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Resume a paused classification pipeline from its last completed offset.
169
+ */
170
+ @Mutation(() => AutotagPipelineResult)
171
+ async ResumeClassificationPipeline(
172
+ @Arg('processRunID') processRunID: string,
173
+ @Ctx() { userPayload }: AppContext = {} as AppContext
174
+ ): Promise<AutotagPipelineResult> {
175
+ try {
176
+ const currentUser = this.GetUserFromPayload(userPayload);
177
+ if (!currentUser) {
178
+ return { Success: false, Status: 'Error', ErrorMessage: 'Unable to determine current user' };
179
+ }
180
+
181
+ const md = new Metadata();
182
+ const run = await md.GetEntityObject<MJContentProcessRunEntity>('MJ: Content Process Runs', currentUser);
183
+ const loaded = await run.Load(processRunID);
184
+ if (!loaded) {
185
+ return { Success: false, Status: 'Error', ErrorMessage: `Process run ${processRunID} not found` };
186
+ }
187
+
188
+ if (run.Status !== 'Paused') {
189
+ return { Success: false, Status: 'Error', ErrorMessage: `Run is not paused (Status: ${run.Status})` };
190
+ }
191
+
192
+ // Reset cancellation flag and set status back to Running
193
+ run.CancellationRequested = false;
194
+ run.Status = 'Running';
195
+ await run.Save();
196
+
197
+ // Fire-and-forget: resume pipeline in background from the last offset
198
+ const pipelineRunID = uuidv4();
199
+ LogStatus(`ResumeClassificationPipeline: Resuming run ${processRunID} from offset ${run.LastProcessedOffset}`);
200
+
201
+ this.runPipelineInBackground(pipelineRunID, currentUser, undefined, undefined);
202
+
203
+ return { Success: true, Status: 'Resumed', PipelineRunID: pipelineRunID };
204
+ } catch (error) {
205
+ const msg = error instanceof Error ? error.message : String(error);
206
+ return { Success: false, Status: 'Error', ErrorMessage: msg };
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Publish a progress update to the PipelineProgress subscription topic.
212
+ */
213
+ private publishProgress(
214
+ pipelineRunID: string,
215
+ stage: string,
216
+ totalItems: number,
217
+ processedItems: number,
218
+ startTime: number,
219
+ currentItem?: string
220
+ ): void {
221
+ const elapsedMs = Date.now() - startTime;
222
+ const percentComplete = totalItems > 0 ? Math.round((processedItems / totalItems) * 100) : 0;
223
+
224
+ const notification: PipelineProgressNotification = {
225
+ PipelineRunID: pipelineRunID,
226
+ Stage: stage,
227
+ TotalItems: totalItems,
228
+ ProcessedItems: processedItems,
229
+ CurrentItem: currentItem,
230
+ ElapsedMs: elapsedMs,
231
+ PercentComplete: percentComplete,
232
+ };
233
+ PubSubManager.Instance.Publish(PIPELINE_PROGRESS_TOPIC, { ...notification });
234
+ }
235
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @fileoverview GraphQL resolver for client tool request/response communication.
3
+ *
4
+ * Provides:
5
+ * - Subscription: Client subscribes to receive tool requests for a session
6
+ * - Mutation: Client sends tool execution responses back to the server
7
+ * - Mutation: Client sends enriched tool definitions after decoration
8
+ *
9
+ * @module @memberjunction/server
10
+ */
11
+
12
+ import { Resolver, Subscription, Root, ObjectType, Field, Float, Mutation, Arg, Ctx } from 'type-graphql';
13
+ import { AppContext } from '../types.js';
14
+ import { LogStatus, LogError } from '@memberjunction/core';
15
+ import { ResolverBase } from '../generic/ResolverBase.js';
16
+ import { ClientToolRequestManager, CLIENT_TOOL_REQUEST_TOPIC, ClientToolRequestNotificationPayload } from '@memberjunction/ai-agents';
17
+
18
+ @ObjectType()
19
+ export class ClientToolRequestNotification {
20
+ @Field()
21
+ AgentRunID: string;
22
+
23
+ @Field()
24
+ SessionID: string;
25
+
26
+ @Field()
27
+ RequestID: string;
28
+
29
+ @Field()
30
+ ToolName: string;
31
+
32
+ /** JSON-encoded parameters */
33
+ @Field()
34
+ Params: string;
35
+
36
+ @Field(() => Float)
37
+ TimeoutMs: number;
38
+
39
+ @Field({ nullable: true })
40
+ Description?: string;
41
+ }
42
+
43
+ @Resolver()
44
+ export class ClientToolRequestResolver extends ResolverBase {
45
+ /**
46
+ * Subscribe to client tool requests for a specific session.
47
+ * The client listens on this subscription to know when an agent
48
+ * wants to invoke a browser-side tool.
49
+ */
50
+ @Subscription(() => ClientToolRequestNotification, {
51
+ topics: CLIENT_TOOL_REQUEST_TOPIC,
52
+ filter: ({ payload, args }: { payload: ClientToolRequestNotificationPayload; args: { sessionID: string } }) => {
53
+ return payload.SessionID === args.sessionID;
54
+ },
55
+ })
56
+ ClientToolRequest(
57
+ @Root() notification: ClientToolRequestNotificationPayload,
58
+ @Arg('sessionID') _sessionID: string
59
+ ): ClientToolRequestNotification {
60
+ return {
61
+ AgentRunID: notification.AgentRunID,
62
+ SessionID: notification.SessionID,
63
+ RequestID: notification.RequestID,
64
+ ToolName: notification.ToolName,
65
+ Params: notification.Params,
66
+ TimeoutMs: notification.TimeoutMs,
67
+ Description: notification.Description
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Client sends the result of executing a client tool back to the server.
73
+ * This resolves the pending Promise in ClientToolRequestManager so the
74
+ * agent loop can continue.
75
+ */
76
+ @Mutation(() => Boolean)
77
+ async RespondToClientToolRequest(
78
+ @Arg('requestID') requestID: string,
79
+ @Arg('success') success: boolean,
80
+ @Arg('result', { nullable: true }) result: string | undefined,
81
+ @Arg('errorMessage', { nullable: true }) errorMessage: string | undefined,
82
+ @Ctx() _context: AppContext = {} as AppContext
83
+ ): Promise<boolean> {
84
+ try {
85
+ const found = ClientToolRequestManager.Instance.ReceiveResponse({
86
+ RequestID: requestID,
87
+ Success: success,
88
+ Result: result ? JSON.parse(result) : undefined,
89
+ ErrorMessage: errorMessage
90
+ });
91
+
92
+ if (!found) {
93
+ LogError(`RespondToClientToolRequest: no pending request for ${requestID} (may have timed out)`);
94
+ }
95
+ return found;
96
+ } catch (error) {
97
+ const msg = error instanceof Error ? error.message : String(error);
98
+ LogError(`RespondToClientToolRequest error: ${msg}`);
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Client sends enriched tool definitions after running decorators.
105
+ * The server stores them per session for LLM prompt injection.
106
+ */
107
+ @Mutation(() => Boolean)
108
+ async UpdateClientToolDefinitions(
109
+ @Arg('sessionID') sessionID: string,
110
+ @Arg('tools') toolsJson: string,
111
+ @Ctx() _context: AppContext = {} as AppContext
112
+ ): Promise<boolean> {
113
+ try {
114
+ const tools = JSON.parse(toolsJson);
115
+ if (!Array.isArray(tools)) {
116
+ LogError('UpdateClientToolDefinitions: tools must be a JSON array');
117
+ return false;
118
+ }
119
+ ClientToolRequestManager.Instance.SetSessionTools(sessionID, tools);
120
+ LogStatus(`UpdateClientToolDefinitions: stored ${tools.length} tools for session ${sessionID}`);
121
+ return true;
122
+ } catch (error) {
123
+ const msg = error instanceof Error ? error.message : String(error);
124
+ LogError(`UpdateClientToolDefinitions error: ${msg}`);
125
+ return false;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,238 @@
1
+ import { Resolver, Query, Arg, Ctx, ObjectType, Field, Float, Int } from 'type-graphql';
2
+ import { AppContext } from '../types.js';
3
+ import { LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
+ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } from '@memberjunction/core-entities';
5
+ import { ResolverBase } from '../generic/ResolverBase.js';
6
+ import { GetAIAPIKey } from '@memberjunction/ai';
7
+ import { VectorDBBase } from '@memberjunction/ai-vectordb';
8
+ import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
9
+
10
+ /* ───── GraphQL types ───── */
11
+
12
+ @ObjectType()
13
+ export class EntityVectorItem {
14
+ @Field()
15
+ ID: string;
16
+
17
+ @Field(() => [Float])
18
+ Values: number[];
19
+
20
+ @Field(() => String)
21
+ Metadata: string;
22
+ }
23
+
24
+ @ObjectType()
25
+ export class FetchEntityVectorsResult {
26
+ @Field()
27
+ Success: boolean;
28
+
29
+ @Field(() => [EntityVectorItem])
30
+ Results: EntityVectorItem[];
31
+
32
+ @Field()
33
+ TotalCount: number;
34
+
35
+ @Field()
36
+ ElapsedMs: number;
37
+
38
+ @Field({ nullable: true })
39
+ ErrorMessage?: string;
40
+ }
41
+
42
+ /* ───── Resolver ───── */
43
+
44
+ @Resolver()
45
+ export class FetchEntityVectorsResolver extends ResolverBase {
46
+
47
+ @Query(() => FetchEntityVectorsResult)
48
+ async FetchEntityVectors(
49
+ @Arg('entityDocumentID') entityDocumentID: string,
50
+ @Arg('maxRecords', () => Int, { nullable: true }) maxRecords: number | undefined,
51
+ @Arg('filter', { nullable: true }) filter: string | undefined,
52
+ @Ctx() { userPayload }: AppContext = {} as AppContext
53
+ ): Promise<FetchEntityVectorsResult> {
54
+ const startTime = Date.now();
55
+ try {
56
+ const currentUser = this.GetUserFromPayload(userPayload);
57
+ if (!currentUser) {
58
+ return this.errorResult('Unable to determine current user', startTime);
59
+ }
60
+
61
+ const limit = maxRecords ?? 1000;
62
+
63
+ // Step 1: Load the EntityDocument
64
+ const entityDoc = await this.loadEntityDocument(entityDocumentID, currentUser);
65
+ if (!entityDoc) {
66
+ return this.errorResult(`EntityDocument not found: ${entityDocumentID}`, startTime);
67
+ }
68
+
69
+ // Step 2: Resolve the VectorIndex
70
+ const vectorIndex = await this.resolveVectorIndex(entityDoc, currentUser);
71
+ if (!vectorIndex) {
72
+ return this.errorResult(
73
+ `Could not resolve VectorIndex for EntityDocument "${entityDoc.Name}"`,
74
+ startTime
75
+ );
76
+ }
77
+
78
+ // Step 3: Create VectorDB provider instance
79
+ const vectorDBInstance = await this.createVectorDBInstance(vectorIndex, currentUser);
80
+ if (!vectorDBInstance) {
81
+ return this.errorResult(
82
+ `Could not create VectorDB provider for index "${vectorIndex.Name}"`,
83
+ startTime
84
+ );
85
+ }
86
+
87
+ // Step 4: Query with zero vector + Entity metadata filter.
88
+ // Pinecone's list API doesn't support metadata filtering, but query does.
89
+ // A zero vector returns results in arbitrary order (similarity is meaningless),
90
+ // but the metadata filter ensures we only get vectors for this entity.
91
+ const entityName = entityDoc.Entity;
92
+ const dimensions = vectorIndex.Dimensions || 1536; // fall back to common embedding size
93
+ // Use a tiny uniform vector instead of zero — cosine similarity is undefined
94
+ // for a zero vector (division by zero), causing Pinecone to return 0 matches.
95
+ // A uniform vector has equal similarity to all vectors, giving us an unbiased
96
+ // listing that respects the metadata filter.
97
+ const uniformVector = new Array(dimensions).fill(1.0 / Math.sqrt(dimensions));
98
+
99
+ const metadataFilter: Record<string, unknown> = { Entity: { $eq: entityName } };
100
+
101
+ const queryResponse = await vectorDBInstance.QueryIndex({
102
+ id: vectorIndex.Name, // index name (stripped before Pinecone query)
103
+ vector: uniformVector,
104
+ topK: limit,
105
+ includeMetadata: true,
106
+ includeValues: true,
107
+ filter: metadataFilter,
108
+ });
109
+
110
+ if (!queryResponse.success || !queryResponse.data) {
111
+ return this.errorResult(
112
+ `Vector query failed for entity "${entityName}" in index "${vectorIndex.Name}"`,
113
+ startTime
114
+ );
115
+ }
116
+
117
+ // Step 5: Convert query matches to result format
118
+ const matches = (queryResponse.data as { matches?: Array<{ id: string; values?: number[]; metadata?: Record<string, unknown>; score?: number }> }).matches ?? [];
119
+ const results: EntityVectorItem[] = matches.map(match => ({
120
+ ID: match.id,
121
+ Values: match.values ?? [],
122
+ Metadata: JSON.stringify(match.metadata ?? {}),
123
+ }));
124
+ LogStatus(`FetchEntityVectors: Queried ${results.length} vectors for entity "${entityName}" in ${Date.now() - startTime}ms`);
125
+
126
+ return {
127
+ Success: true,
128
+ Results: results,
129
+ TotalCount: results.length,
130
+ ElapsedMs: Date.now() - startTime,
131
+ };
132
+ } catch (error) {
133
+ const msg = error instanceof Error ? error.message : String(error);
134
+ LogError(`FetchEntityVectors query failed: ${msg}`);
135
+ return this.errorResult(msg, startTime);
136
+ }
137
+ }
138
+
139
+ /** Load EntityDocument by ID */
140
+ private async loadEntityDocument(
141
+ entityDocumentID: string,
142
+ contextUser: UserInfo
143
+ ): Promise<MJEntityDocumentEntity | null> {
144
+ const rv = new RunView();
145
+ const result = await rv.RunView<MJEntityDocumentEntity>({
146
+ EntityName: 'MJ: Entity Documents',
147
+ ExtraFilter: `ID='${entityDocumentID}'`,
148
+ ResultType: 'entity_object',
149
+ }, contextUser);
150
+
151
+ if (!result.Success || result.Results.length === 0) {
152
+ return null;
153
+ }
154
+ return result.Results[0];
155
+ }
156
+
157
+ /**
158
+ * Resolve the VectorIndex for an EntityDocument.
159
+ * Prefers the explicit VectorIndexID on the EntityDocument; falls back to
160
+ * finding a VectorIndex by matching VectorDatabaseID + EmbeddingModelID.
161
+ */
162
+ private async resolveVectorIndex(
163
+ entityDoc: MJEntityDocumentEntity,
164
+ contextUser: UserInfo
165
+ ): Promise<MJVectorIndexEntity | null> {
166
+ const rv = new RunView();
167
+
168
+ // If the EntityDocument has an explicit VectorIndexID, use it directly
169
+ if (entityDoc.VectorIndexID) {
170
+ const result = await rv.RunView<MJVectorIndexEntity>({
171
+ EntityName: 'MJ: Vector Indexes',
172
+ ExtraFilter: `ID='${entityDoc.VectorIndexID}'`,
173
+ ResultType: 'entity_object',
174
+ }, contextUser);
175
+
176
+ if (result.Success && result.Results.length > 0) {
177
+ return result.Results[0];
178
+ }
179
+ LogError(`FetchEntityVectors: VectorIndex ${entityDoc.VectorIndexID} referenced by EntityDocument not found`);
180
+ }
181
+
182
+ // Fallback: find a VectorIndex by VectorDatabaseID + matching AIModelID as EmbeddingModelID
183
+ const indexResult = await rv.RunView<MJVectorIndexEntity>({
184
+ EntityName: 'MJ: Vector Indexes',
185
+ ExtraFilter: `VectorDatabaseID='${entityDoc.VectorDatabaseID}'`,
186
+ ResultType: 'entity_object',
187
+ }, contextUser);
188
+
189
+ if (!indexResult.Success || indexResult.Results.length === 0) {
190
+ return null;
191
+ }
192
+
193
+ // Match on EmbeddingModelID = EntityDocument.AIModelID
194
+ const match = indexResult.Results.find(idx =>
195
+ UUIDsEqual(idx.EmbeddingModelID, entityDoc.AIModelID)
196
+ );
197
+ return match ?? indexResult.Results[0];
198
+ }
199
+
200
+ /** Create a VectorDBBase provider instance for a given VectorIndex */
201
+ private async createVectorDBInstance(
202
+ vectorIndex: MJVectorIndexEntity,
203
+ contextUser: UserInfo
204
+ ): Promise<VectorDBBase | null> {
205
+ const rv = new RunView();
206
+ const dbResult = await rv.RunView<MJVectorDatabaseEntity>({
207
+ EntityName: 'MJ: Vector Databases',
208
+ ExtraFilter: `ID='${vectorIndex.VectorDatabaseID}'`,
209
+ ResultType: 'entity_object',
210
+ }, contextUser);
211
+
212
+ if (!dbResult.Success || dbResult.Results.length === 0) {
213
+ LogError(`FetchEntityVectors: VectorDatabase not found for index "${vectorIndex.Name}"`);
214
+ return null;
215
+ }
216
+
217
+ const vectorDB = dbResult.Results[0];
218
+ const apiKey = GetAIAPIKey(vectorDB.ClassKey);
219
+ const instance = MJGlobal.Instance.ClassFactory.CreateInstance<VectorDBBase>(
220
+ VectorDBBase, vectorDB.ClassKey, apiKey
221
+ );
222
+
223
+ if (!instance) {
224
+ LogError(`FetchEntityVectors: Failed to create VectorDB instance for ClassKey "${vectorDB.ClassKey}"`);
225
+ }
226
+ return instance;
227
+ }
228
+
229
+ private errorResult(message: string, startTime: number): FetchEntityVectorsResult {
230
+ return {
231
+ Success: false,
232
+ Results: [],
233
+ TotalCount: 0,
234
+ ElapsedMs: Date.now() - startTime,
235
+ ErrorMessage: message,
236
+ };
237
+ }
238
+ }