@memberjunction/server 2.35.0 → 2.36.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 (52) hide show
  1. package/README.md +15 -1
  2. package/dist/config.d.ts +69 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +11 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/generated/generated.d.ts +15 -12
  7. package/dist/generated/generated.d.ts.map +1 -1
  8. package/dist/generated/generated.js +73 -58
  9. package/dist/generated/generated.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +41 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/resolvers/AskSkipResolver.d.ts +60 -5
  15. package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
  16. package/dist/resolvers/AskSkipResolver.js +587 -31
  17. package/dist/resolvers/AskSkipResolver.js.map +1 -1
  18. package/dist/rest/EntityCRUDHandler.d.ts +29 -0
  19. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -0
  20. package/dist/rest/EntityCRUDHandler.js +197 -0
  21. package/dist/rest/EntityCRUDHandler.js.map +1 -0
  22. package/dist/rest/RESTEndpointHandler.d.ts +41 -0
  23. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -0
  24. package/dist/rest/RESTEndpointHandler.js +537 -0
  25. package/dist/rest/RESTEndpointHandler.js.map +1 -0
  26. package/dist/rest/ViewOperationsHandler.d.ts +21 -0
  27. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -0
  28. package/dist/rest/ViewOperationsHandler.js +144 -0
  29. package/dist/rest/ViewOperationsHandler.js.map +1 -0
  30. package/dist/rest/index.d.ts +5 -0
  31. package/dist/rest/index.d.ts.map +1 -0
  32. package/dist/rest/index.js +5 -0
  33. package/dist/rest/index.js.map +1 -0
  34. package/dist/rest/setupRESTEndpoints.d.ts +12 -0
  35. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -0
  36. package/dist/rest/setupRESTEndpoints.js +27 -0
  37. package/dist/rest/setupRESTEndpoints.js.map +1 -0
  38. package/dist/scheduler/LearningCycleScheduler.d.ts +44 -0
  39. package/dist/scheduler/LearningCycleScheduler.d.ts.map +1 -0
  40. package/dist/scheduler/LearningCycleScheduler.js +188 -0
  41. package/dist/scheduler/LearningCycleScheduler.js.map +1 -0
  42. package/package.json +24 -26
  43. package/src/config.ts +15 -1
  44. package/src/generated/generated.ts +53 -44
  45. package/src/index.ts +56 -1
  46. package/src/resolvers/AskSkipResolver.ts +787 -51
  47. package/src/rest/EntityCRUDHandler.ts +279 -0
  48. package/src/rest/RESTEndpointHandler.ts +834 -0
  49. package/src/rest/ViewOperationsHandler.ts +207 -0
  50. package/src/rest/index.ts +4 -0
  51. package/src/rest/setupRESTEndpoints.ts +89 -0
  52. package/src/scheduler/LearningCycleScheduler.ts +312 -0
@@ -1,11 +1,12 @@
1
- import { Arg, Ctx, Field, Int, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
2
- import { LogError, LogStatus, Metadata, KeyValuePair, RunView, UserInfo, CompositeKey, EntityFieldInfo, EntityInfo, EntityRelationshipInfo } from '@memberjunction/core';
3
- import { AppContext, UserPayload } from '../types.js';
1
+ import { Arg, Ctx, Field, Int, Mutation, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
2
+ import { LogError, LogStatus, Metadata, KeyValuePair, RunView, UserInfo, CompositeKey, EntityFieldInfo, EntityInfo, EntityRelationshipInfo, AllMetadataArrays } from '@memberjunction/core';
3
+ import { AppContext, UserPayload, MJ_SERVER_EVENT_CODE } from '../types.js';
4
4
  import { BehaviorSubject } from 'rxjs';
5
5
  import { take } from 'rxjs/operators';
6
6
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
7
7
  import { DataContext } from '@memberjunction/data-context';
8
8
  import { LoadDataContextItemsServer } from '@memberjunction/data-context-server';
9
+ import { LearningCycleScheduler } from '../scheduler/LearningCycleScheduler.js';
9
10
  LoadDataContextItemsServer(); // prevent tree shaking since the DataContextItemServer class is not directly referenced in this file or otherwise statically instantiated, so it could be removed by the build process
10
11
 
11
12
  import {
@@ -25,10 +26,20 @@ import {
25
26
  SkipEntityFieldInfo,
26
27
  SkipEntityRelationshipInfo,
27
28
  SkipEntityFieldValueInfo,
29
+ SkipAPILearningCycleRequest,
30
+ SkipAPILearningCycleResponse,
31
+ SkipLearningCycleNoteChange,
32
+ SkipConversation,
33
+ SkipAPIArtifact,
34
+ SkipAPIAgentRequest,
28
35
  } from '@memberjunction/skip-types';
29
36
 
30
37
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
38
+
31
39
  import {
40
+ AIAgentLearningCycleEntity,
41
+ AIAgentNoteEntity,
42
+ AIAgentRequestEntity,
32
43
  ConversationDetailEntity,
33
44
  ConversationEntity,
34
45
  DataContextEntity,
@@ -36,7 +47,7 @@ import {
36
47
  UserNotificationEntity,
37
48
  } from '@memberjunction/core-entities';
38
49
  import { DataSource } from 'typeorm';
39
- import { ___skipAPIOrgId, ___skipAPIurl, apiKey, baseUrl, configInfo, graphqlPort, mj_core_schema } from '../config.js';
50
+ import { ___skipAPIOrgId, ___skipAPIurl, ___skipLearningAPIurl, ___skipLearningCycleIntervalInMinutes, apiKey, baseUrl, configInfo, graphqlPort, mj_core_schema } from '../config.js';
40
51
 
41
52
  import { registerEnumType } from 'type-graphql';
42
53
  import { MJGlobal, CopyScalarsAndArrays } from '@memberjunction/global';
@@ -81,9 +92,111 @@ export class AskSkipResultType {
81
92
  AIMessageConversationDetailId: string;
82
93
  }
83
94
 
95
+ @ObjectType()
96
+ export class ManualLearningCycleResultType {
97
+ @Field(() => Boolean)
98
+ Success: boolean;
99
+
100
+ @Field(() => String)
101
+ Message: string;
102
+ }
103
+
104
+ @ObjectType()
105
+ export class CycleDetailsType {
106
+ @Field(() => String)
107
+ LearningCycleId: string;
108
+
109
+ @Field(() => String)
110
+ StartTime: string;
111
+
112
+ @Field(() => Number)
113
+ RunningForMinutes: number;
114
+ }
115
+
116
+ @ObjectType()
117
+ export class RunningOrganizationType {
118
+ @Field(() => String)
119
+ OrganizationId: string;
120
+
121
+ @Field(() => String)
122
+ LearningCycleId: string;
123
+
124
+ @Field(() => String)
125
+ StartTime: string;
126
+
127
+ @Field(() => Number)
128
+ RunningForMinutes: number;
129
+ }
130
+
131
+ @ObjectType()
132
+ export class LearningCycleStatusType {
133
+ @Field(() => Boolean)
134
+ IsSchedulerRunning: boolean;
135
+
136
+ @Field(() => String, { nullable: true })
137
+ LastRunTime: string;
138
+
139
+ @Field(() => [RunningOrganizationType], { nullable: true })
140
+ RunningOrganizations: RunningOrganizationType[];
141
+ }
142
+
143
+ @ObjectType()
144
+ export class StopLearningCycleResultType {
145
+ @Field(() => Boolean)
146
+ Success: boolean;
147
+
148
+ @Field(() => String)
149
+ Message: string;
150
+
151
+ @Field(() => Boolean)
152
+ WasRunning: boolean;
153
+
154
+ @Field(() => CycleDetailsType, { nullable: true })
155
+ CycleDetails: CycleDetailsType;
156
+ }
157
+
84
158
  @Resolver(AskSkipResultType)
85
159
  export class AskSkipResolver {
86
160
  private static _defaultNewChatName = 'New Chat';
161
+
162
+ // Static initializer that runs when the class is loaded - initializes the learning cycle scheduler
163
+ static {
164
+ try {
165
+ LogStatus('Initializing Skip AI Learning Cycle Scheduler');
166
+
167
+ // Set up event listener for server initialization
168
+ const eventListener = MJGlobal.Instance.GetEventListener(true);
169
+ eventListener.subscribe(event => {
170
+ // Filter for our server's setup complete event
171
+ if (event.eventCode === MJ_SERVER_EVENT_CODE && event.args?.type === 'setupComplete') {
172
+ try {
173
+ const dataSources = event.args.dataSources;
174
+ if (dataSources && dataSources.length > 0) {
175
+ // Initialize the scheduler
176
+ const scheduler = LearningCycleScheduler.Instance;
177
+
178
+ // Set the data sources for the scheduler
179
+ scheduler.setDataSources(dataSources);
180
+
181
+ // Default is 60 minutes, if the interval is not set in the config, use 60 minutes
182
+ const interval = ___skipLearningCycleIntervalInMinutes ?? 60;
183
+ scheduler.start(interval);
184
+ LogStatus(`📅 Skip AI Learning cycle scheduler started with ${interval} minute interval`);
185
+ } else {
186
+ LogError('Cannot initialize Skip learning cycle scheduler: No data sources available');
187
+ }
188
+ } catch (error) {
189
+ LogError(`Error initializing Skip learning cycle scheduler: ${error}`);
190
+ }
191
+ }
192
+ });
193
+
194
+ LogStatus('Skip AI Learning Cycle Scheduler initialization listener registered');
195
+ } catch (error) {
196
+ // Handle any errors from the static initializer
197
+ LogError(`Failed to initialize Skip learning cycle scheduler: ${error}`);
198
+ }
199
+ }
87
200
  private static _maxHistoricalMessages = 30;
88
201
 
89
202
  /**
@@ -118,7 +231,7 @@ export class AskSkipResolver {
118
231
  }
119
232
 
120
233
  const md = new Metadata();
121
- const { convoEntity, dataContextEntity, convoDetailEntity, dataContext } = await this.HandleSkipInitialObjectLoading(
234
+ const { convoEntity, dataContextEntity, convoDetailEntity, dataContext } = await this.HandleSkipChatInitialObjectLoading(
122
235
  dataSource,
123
236
  ConversationId,
124
237
  UserQuestion,
@@ -155,24 +268,175 @@ export class AskSkipResolver {
155
268
  }
156
269
  }
157
270
 
158
- const input = await this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'chat_with_a_record', false, false, false, user, dataSource, false, false);
271
+ const input = await this.buildSkipChatAPIRequest(messages, ConversationId, dataContext, 'chat_with_a_record', false, false, false, false, user, dataSource, false, false);
159
272
  messages.push({
160
273
  content: UserQuestion,
161
274
  role: 'user',
162
275
  conversationDetailID: convoDetailEntity.ID,
163
276
  });
164
277
 
165
- return this.handleSimpleSkipPostRequest(input, convoEntity.ID, convoDetailEntity.ID, true, user);
278
+ return this.handleSimpleSkipChatPostRequest(input, convoEntity.ID, convoDetailEntity.ID, true, user);
166
279
  }
167
280
 
168
- protected async handleSimpleSkipPostRequest(
281
+ @Mutation(() => AskSkipResultType)
282
+ async ExecuteAskSkipLearningCycle(
283
+ @Ctx() { dataSource, userPayload }: AppContext,
284
+ @Arg('ForceEntityRefresh', () => Boolean, { nullable: true }) ForceEntityRefresh?: boolean
285
+ ) {
286
+ const startTime = new Date();
287
+ // First, get the user from the cache
288
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
289
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
290
+
291
+ // if already configured this does nothing, just makes sure we're configured
292
+ await AIEngine.Instance.Config(false, user);
293
+
294
+ // Check if this organization is already running a learning cycle using their organization ID
295
+ const organizationId = ___skipAPIOrgId;
296
+ const scheduler = LearningCycleScheduler.Instance;
297
+ const runningStatus = scheduler.isOrganizationRunningCycle(organizationId);
298
+
299
+ if (runningStatus.isRunning) {
300
+ LogStatus(`Learning cycle already in progress for organization ${organizationId}, started at ${runningStatus.startTime.toISOString()}`);
301
+ return {
302
+ success: false,
303
+ error: `Learning cycle already in progress for this organization (started ${Math.round(runningStatus.runningForMinutes)} minutes ago)`,
304
+ elapsedTime: 0,
305
+ noteChanges: [],
306
+ queryChanges: [],
307
+ requestChanges: []
308
+ };
309
+ }
310
+
311
+ LogStatus(`Starting learning cycle for AI agent Skip`);
312
+
313
+ // Get the Skip agent ID
314
+ const md = new Metadata();
315
+ const skipAgent = AIEngine.Instance.GetAgentByName('Skip');
316
+ if (!skipAgent) {
317
+ throw new Error("Skip agent not found in AIEngine");
318
+ }
319
+
320
+ const agentID = skipAgent.ID;
321
+
322
+ // Get last complete learning cycle start date for this agent
323
+ const lastCompleteLearningCycleDate = await this.GetLastCompleteLearningCycleDate(agentID, user);
324
+
325
+ // Create a new learning cycle record for this run
326
+ const learningCycleEntity = await md.GetEntityObject<AIAgentLearningCycleEntity>('AI Agent Learning Cycles', user);
327
+ learningCycleEntity.NewRecord();
328
+ learningCycleEntity.AgentID = skipAgent.ID;
329
+ learningCycleEntity.Status = 'In-Progress';
330
+ learningCycleEntity.StartedAt = startTime;
331
+
332
+ if (!(await learningCycleEntity.Save())) {
333
+ throw new Error(`Failed to create learning cycle record: ${learningCycleEntity.LatestResult.Error}`);
334
+ }
335
+
336
+ const learningCycleId = learningCycleEntity.ID;
337
+ LogStatus(`Created new learning cycle with ID: ${learningCycleId}`);
338
+
339
+ // Register this organization as running a learning cycle
340
+ scheduler.registerRunningCycle(organizationId, learningCycleId);
341
+
342
+ try {
343
+ // Build the request to Skip learning API
344
+ LogStatus(`Building Skip Learning API request`);
345
+ const input = await this.buildSkipLearningAPIRequest(learningCycleId, lastCompleteLearningCycleDate, true, true, true, true, dataSource, user, ForceEntityRefresh || false);
346
+
347
+ // Make the API request
348
+ const response = await this.handleSimpleSkipLearningPostRequest(input, user, learningCycleId, agentID);
349
+
350
+ // Update learning cycle to completed
351
+ const endTime = new Date();
352
+ const elapsedTimeMs = endTime.getTime() - startTime.getTime();
353
+
354
+ LogStatus(`Learning cycle finished with status: ${response.success ? 'Success' : 'Failed'} in ${elapsedTimeMs / 1000} seconds`);
355
+
356
+ learningCycleEntity.Status = response.success ? 'Complete' : 'Failed';
357
+ learningCycleEntity.EndedAt = endTime;
358
+
359
+ if (!(await learningCycleEntity.Save())) {
360
+ LogError(`Failed to update learning cycle record: ${learningCycleEntity.LatestResult.Error}`);
361
+ }
362
+
363
+ // Unregister the organization after completion
364
+ scheduler.unregisterRunningCycle(organizationId);
365
+
366
+ return response;
367
+ } catch (error) {
368
+ // Make sure to update the learning cycle record as failed
369
+ learningCycleEntity.Status = 'Failed';
370
+ learningCycleEntity.EndedAt = new Date();
371
+
372
+ try {
373
+ await learningCycleEntity.Save();
374
+ } catch (saveError) {
375
+ LogError(`Failed to update learning cycle record after error: ${saveError}`);
376
+ }
377
+
378
+ // Unregister the organization on error
379
+ scheduler.unregisterRunningCycle(organizationId);
380
+
381
+ // Re-throw the original error
382
+ throw error;
383
+ }
384
+ }
385
+
386
+ protected async handleSimpleSkipLearningPostRequest(
387
+ input: SkipAPILearningCycleRequest,
388
+ user: UserInfo,
389
+ learningCycleId: string,
390
+ agentID: string
391
+ ): Promise<SkipAPILearningCycleResponse> {
392
+ LogStatus(` >>> HandleSimpleSkipLearningPostRequest Sending request to Skip API: ${___skipLearningAPIurl}`);
393
+
394
+ const response = await sendPostRequest(___skipLearningAPIurl, input, true, null);
395
+
396
+ if (response && response.length > 0) {
397
+ // the last object in the response array is the final response from the Skip API
398
+ const apiResponse = <SkipAPILearningCycleResponse>response[response.length - 1].value;
399
+ LogStatus(` Skip API response: ${apiResponse.success}`);
400
+
401
+ // Process any note changes, if any
402
+ if (apiResponse.noteChanges && apiResponse.noteChanges.length > 0) {
403
+ await this.processLearningCycleNoteChanges(apiResponse.noteChanges, agentID, user);
404
+ }
405
+
406
+ // Not yet implemented
407
+
408
+ // // Process any query changes, if any
409
+ // if (apiResponse.queryChanges && apiResponse.queryChanges.length > 0) {
410
+ // await this.processLearningCycleQueryChanges(apiResponse.queryChanges, user);
411
+ // }
412
+
413
+ // // Process any request changes, if any
414
+ // if (apiResponse.requestChanges && apiResponse.requestChanges.length > 0) {
415
+ // await this.processLearningCycleRequestChanges(apiResponse.requestChanges, user);
416
+ // }
417
+
418
+ return apiResponse;
419
+ } else {
420
+ return {
421
+ success: false,
422
+ error: 'Error',
423
+ elapsedTime: 0,
424
+ noteChanges: [],
425
+ queryChanges: [],
426
+ requestChanges: [],
427
+ };
428
+
429
+ }
430
+ }
431
+
432
+ protected async handleSimpleSkipChatPostRequest(
169
433
  input: SkipAPIRequest,
170
434
  conversationID: string = '',
171
435
  UserMessageConversationDetailId: string = '',
172
436
  createAIMessageConversationDetail: boolean = false,
173
437
  user: UserInfo = null
174
438
  ): Promise<AskSkipResultType> {
175
- LogStatus(` >>> HandleSimpleSkipPostRequest Sending request to Skip API: ${___skipAPIurl}`);
439
+ LogStatus(` >>> HandleSimpleSkipChatPostRequest Sending request to Skip API: ${___skipAPIurl}`);
176
440
 
177
441
  const response = await sendPostRequest(___skipAPIurl, input, true, null);
178
442
 
@@ -206,6 +470,117 @@ export class AskSkipResolver {
206
470
  }
207
471
  }
208
472
 
473
+ /**
474
+ * Processes note changes received from the Skip API learning cycle.
475
+ * @param noteChanges Changes to agent notes
476
+ * @param user The user making the request
477
+ */
478
+ protected async processLearningCycleNoteChanges(
479
+ noteChanges: SkipLearningCycleNoteChange[],
480
+ agentID: string,
481
+ user: UserInfo
482
+ ): Promise<void> {
483
+ const md = new Metadata();
484
+
485
+ // Filter out any operations on "Human" notes
486
+ const validNoteChanges = noteChanges.filter(change => {
487
+ // Check if the note is of type "Human"
488
+ if (change.note.agentNoteType === "Human") {
489
+ LogStatus(`WARNING: Ignoring ${change.changeType} operation on Human note with ID ${change.note.id}. Human notes cannot be modified by the
490
+ learning cycle.`);
491
+ return false;
492
+ }
493
+ return true;
494
+ });
495
+
496
+ // Process all valid note changes in parallel
497
+ await Promise.all(validNoteChanges.map(async (change) => {
498
+ try {
499
+ if (change.changeType === 'add' || change.changeType === 'update') {
500
+ await this.processAddOrUpdateSkipNote(change, agentID, user);
501
+ } else if (change.changeType === 'delete') {
502
+ await this.processDeleteSkipNote(change, user);
503
+ }
504
+ } catch (e) {
505
+ LogError(`Error processing note change: ${e}`);
506
+ }
507
+ }));
508
+ }
509
+
510
+ protected async processAddOrUpdateSkipNote(change: SkipLearningCycleNoteChange, agentID: string, user: UserInfo): Promise<boolean> {
511
+ try {
512
+ // Get the note entity object
513
+ const md = new Metadata();
514
+ const noteEntity = await md.GetEntityObject<AIAgentNoteEntity>('AI Agent Notes', user);
515
+
516
+ if (change.changeType === 'update') {
517
+ // Load existing note
518
+ const loadResult = await noteEntity.Load(change.note.id);
519
+ if (!loadResult) {
520
+ LogError(`Could not load note with ID ${change.note.id}`);
521
+ return false;
522
+ }
523
+ } else {
524
+ // For new notes, ensure the note type is not "Human"
525
+ if (change.note.agentNoteType === "Human") {
526
+ LogStatus(`WARNING: Cannot create a new Human note with the learning cycle. Operation ignored.`);
527
+ return false;
528
+ }
529
+
530
+ // Create a new note
531
+ noteEntity.NewRecord();
532
+ noteEntity.AgentID = agentID;
533
+ }
534
+ noteEntity.AgentNoteTypeID = this.getAgentNoteTypeIDByName('AI');
535
+ noteEntity.Note = change.note.note;
536
+ noteEntity.Type = change.note.type;
537
+
538
+ if (change.note.type === 'User') {
539
+ noteEntity.UserID = change.note.userId;
540
+ }
541
+
542
+ // Save the note
543
+ if (!(await noteEntity.Save())) {
544
+ LogError(`Error saving AI Agent Note: ${noteEntity.LatestResult.Error}`);
545
+ return false;
546
+ }
547
+
548
+ return true;
549
+ } catch (e) {
550
+ LogError(`Error processing note change: ${e}`);
551
+ return false;
552
+ }
553
+ }
554
+
555
+ protected async processDeleteSkipNote(change: SkipLearningCycleNoteChange, user: UserInfo): Promise<boolean> {
556
+ // Get the note entity object
557
+ const md = new Metadata();
558
+ const noteEntity = await md.GetEntityObject<AIAgentNoteEntity>('AI Agent Notes', user);
559
+
560
+ // Load the note first
561
+ const loadResult = await noteEntity.Load(change.note.id);
562
+
563
+ if (!loadResult) {
564
+ LogError(`Could not load note with ID ${change.note.id} for deletion`);
565
+ return false;
566
+ }
567
+
568
+ // Double-check if the loaded note is of type "Human"
569
+ if (change.note.agentNoteType === "Human") {
570
+ LogStatus(`WARNING: Ignoring delete operation on Human note with ID ${change.note.id}. Human notes cannot be deleted by the learning
571
+ cycle.`);
572
+ return false;
573
+ }
574
+
575
+ // Proceed with deletion
576
+ if (!(await noteEntity.Delete())) {
577
+ LogError(`Error deleting AI Agent Note: ${noteEntity.LatestResult.Error}`);
578
+ return false;
579
+ }
580
+
581
+ return true;
582
+ }
583
+
209
584
  protected async CreateAIMessageConversationDetail(apiResponse: SkipAPIResponse, conversationID: string, user: UserInfo): Promise<string> {
210
585
  const md = new Metadata();
211
586
  const convoDetailEntityAI = <ConversationDetailEntity>await md.GetEntityObject('Conversation Details', user);
@@ -228,56 +603,275 @@ export class AskSkipResolver {
228
603
  }
229
604
  }
230
605
 
231
- protected async buildSkipAPIRequest(
232
- messages: SkipMessage[],
233
- conversationId: string,
234
- dataContext: DataContext,
235
- requestPhase: SkipRequestPhase,
606
+ /**
607
+ * Builds the base Skip API request with common fields and data
608
+ * @param contextUser The user making the request
609
+ * @param dataSource The data source to use
610
+ * @param includeEntities Whether to include entities in the request
611
+ * @param includeQueries Whether to include queries in the request
612
+ * @param includeNotes Whether to include agent notes in the request
613
+ * @param forceEntitiesRefresh Whether to force refresh of entities
614
+ * @param includeCallBackKeyAndAccessToken Whether to include a callback key and access token
615
+ * @param additionalTokenInfo Additional info to include in the access token
616
+ * @returns Base request data that can be used by specific request builders
617
+ */
618
+ protected async buildBaseSkipRequest(
619
+ contextUser: UserInfo,
620
+ dataSource: DataSource,
236
621
  includeEntities: boolean,
237
622
  includeQueries: boolean,
238
623
  includeNotes: boolean,
239
- contextUser: UserInfo,
240
- dataSource: DataSource,
624
+ includeRequests: boolean,
241
625
  forceEntitiesRefresh: boolean = false,
242
- includeCallBackKeyAndAccessToken: boolean = false
243
- ): Promise<SkipAPIRequest> {
626
+ includeCallBackKeyAndAccessToken: boolean = false,
627
+ additionalTokenInfo: any = {}
628
+ ) {
629
+
244
630
  const entities = includeEntities ? await this.BuildSkipEntities(dataSource, forceEntitiesRefresh) : [];
245
631
  const queries = includeQueries ? this.BuildSkipQueries() : [];
246
- const {notes, noteTypes} = includeNotes ? await this.BuildSkipAgentNotes(contextUser) : {notes: [], noteTypes: []};
247
-
248
- // setup a secure access token that is short lived for use with the Skip API
632
+ const {notes, noteTypes} = includeNotes ? await this.BuildSkipAgentNotes(contextUser) : {notes: [], noteTypes: []};
633
+ const requests = includeRequests ? await this.BuildSkipRequests(contextUser) : [];
634
+
635
+ // Setup access token if needed
249
636
  let accessToken: GetDataAccessToken;
250
637
  if (includeCallBackKeyAndAccessToken) {
638
+ const tokenInfo = {
639
+ type: 'skip_api_request',
640
+ userEmail: contextUser.Email,
641
+ userName: contextUser.Name,
642
+ userID: contextUser.ID,
643
+ ...additionalTokenInfo
644
+ };
645
+
251
646
  accessToken = registerAccessToken(
252
647
  undefined,
253
648
  1000 * 60 * 10 /*10 minutes*/,
254
- {
255
- type: 'skip_api_request',
256
- userEmail: contextUser.Email,
257
- userName: contextUser.Name,
258
- userID: contextUser.ID,
259
- conversationId: conversationId,
260
- requestPhase: requestPhase,
261
- }
262
- );
649
+ tokenInfo
650
+ );
263
651
  }
264
-
265
- const input: SkipAPIRequest = {
266
- apiKeys: this.buildSkipAPIKeys(),
652
+
653
+ return {
654
+ entities,
655
+ queries,
656
+ notes,
657
+ noteTypes,
658
+ requests,
659
+ accessToken,
660
+ organizationId: ___skipAPIOrgId,
267
661
  organizationInfo: configInfo?.askSkip?.organizationInfo,
268
- messages: messages,
269
- conversationID: conversationId.toString(),
270
- dataContext: <DataContext>CopyScalarsAndArrays(dataContext), // we are casting this to DataContext as we're pushing this to the Skip API, and we don't want to send the real DataContext object, just a copy of the scalar and array properties
271
- organizationID: ___skipAPIOrgId,
272
- requestPhase: requestPhase,
273
- entities: entities,
274
- queries: queries,
275
- notes: notes,
276
- noteTypes: noteTypes,
662
+ apiKeys: this.buildSkipAPIKeys(),
277
663
  callingServerURL: accessToken ? `${baseUrl}:${graphqlPort}` : undefined,
278
664
  callingServerAPIKey: accessToken ? apiKey : undefined,
279
665
  callingServerAccessToken: accessToken ? accessToken.Token : undefined
280
666
  };
667
+ }
668
+
669
+ /**
670
+ * Builds the learning API request for Skip
671
+ */
672
+ protected async buildSkipLearningAPIRequest(
673
+ learningCycleId: string,
674
+ lastLearningCycleDate: Date,
675
+ includeEntities: boolean,
676
+ includeQueries: boolean,
677
+ includeNotes: boolean,
678
+ includeRequests: boolean,
679
+ dataSource: DataSource,
680
+ contextUser: UserInfo,
681
+ forceEntitiesRefresh: boolean = false,
682
+ includeCallBackKeyAndAccessToken: boolean = false
683
+ ) {
684
+ // Build base Skip request data
685
+ const baseRequest = await this.buildBaseSkipRequest(
686
+ contextUser,
687
+ dataSource,
688
+ includeEntities,
689
+ includeQueries,
690
+ includeNotes,
691
+ includeRequests,
692
+ forceEntitiesRefresh,
693
+ includeCallBackKeyAndAccessToken
694
+ );
695
+
696
+ // Get data specific to learning cycle
697
+ const newConversations = await this.BuildSkipLearningCycleNewConversations(lastLearningCycleDate, dataSource, contextUser);
698
+
699
+ // Create the learning-specific request object
700
+ const input: SkipAPILearningCycleRequest = {
701
+ organizationId: baseRequest.organizationId,
702
+ organizationInfo: baseRequest.organizationInfo,
703
+ learningCycleId,
704
+ lastLearningCycleDate,
705
+ newConversations,
706
+ entities: baseRequest.entities,
707
+ queries: baseRequest.queries,
708
+ notes: baseRequest.notes,
709
+ noteTypes: baseRequest.noteTypes,
710
+ requests: baseRequest.requests,
711
+ apiKeys: baseRequest.apiKeys
712
+ };
713
+
714
+ return input;
715
+ }
716
+
717
+ /**
718
+ * Loads the conversations that have have an updated or new conversation detail since the last learning cycle
719
+ * @param dataSource the data source to use
720
+ * @param lastLearningCycleDate the date of the last learning cycle
721
+ * @param contextUser the user context
722
+ */
723
+ protected async BuildSkipLearningCycleNewConversations(
724
+ lastLearningCycleDate: Date,
725
+ dataSource: DataSource,
726
+ contextUser: UserInfo
727
+ ): Promise<SkipConversation[]> {
728
+ try {
729
+ const rv = new RunView();
730
+
731
+ // Get all conversations with a conversation detail that has been updated (modified or added) since the last learning cycle
732
+ const conversationsSinceLastLearningCycle = await rv.RunView<ConversationEntity>({
733
+ EntityName: 'Conversations',
734
+ ExtraFilter: `ID IN (SELECT ConversationID FROM __mj.vwConversationDetails WHERE __mj_UpdatedAt >= '${lastLearningCycleDate.toISOString()}')`,
735
+ ResultType: 'entity_object',
736
+ }, contextUser);
737
+
738
+ if (!conversationsSinceLastLearningCycle.Success || conversationsSinceLastLearningCycle.Results.length === 0) {
739
+ return [];
740
+ }
741
+
742
+ // Now we map the conversations to SkipConversations and return
743
+ return await Promise.all(conversationsSinceLastLearningCycle.Results.map(async (c) => {
744
+ return {
745
+ id: c.ID,
746
+ name: c.Name,
747
+ userId: c.UserID,
748
+ user: c.User,
749
+ description: c.Description,
750
+ messages: await this.LoadConversationDetailsIntoSkipMessages(dataSource, c.ID),
751
+ createdAt: c.__mj_CreatedAt,
752
+ updatedAt: c.__mj_UpdatedAt
753
+ };
754
+ }));
755
+ }
756
+ catch (e) {
757
+ LogError(`Error loading conversations since last learning cycle: ${e}`);
758
+ return [];
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Builds an array of agent requests
764
+ * @param contextUser the user context to load the requests
765
+ * @returns Array of SkipAPIAgentRequest objects
766
+ */
767
+ protected async BuildSkipRequests(
768
+ contextUser: UserInfo
769
+ ): Promise<SkipAPIAgentRequest[]> {
770
+ try {
771
+ const md = new Metadata();
772
+ const requestEntity = await md.GetEntityObject<AIAgentRequestEntity>('AI Agent Requests', contextUser);
773
+ const allRequests = await requestEntity.GetAll();
774
+
775
+ const requests = allRequests.map((r) => {
776
+ return {
777
+ id: r.ID,
778
+ agentId: r.AIAgentID,
779
+ agnet: r.AIAgent,
780
+ requestedAt: r.RequestedAt,
781
+ requestForUserId: r.RequestedForUserID,
782
+ requestForUser: r.RequestedForUser,
783
+ status: r.Status,
784
+ request: r.Request,
785
+ response: r.Response,
786
+ responseByUserId: r.ResponseByUserID,
787
+ responseByUser: r.ResponseByUser,
788
+ respondedAt: r.RespondedAt,
789
+ comments: r.Comments,
790
+ createdAt: r.__mj_CreatedAt,
791
+ updatedAt: r.__mj_UpdatedAt,
792
+ };
793
+ });
794
+ return requests;
795
+
796
+ } catch (e) {
797
+ LogError(`Error loading requests: ${e}`);
798
+ return [];
799
+ }
800
+ }
801
+
802
+ protected async GetLastCompleteLearningCycleDate(agentID: string, user: UserInfo): Promise<Date> {
803
+ const md = new Metadata();
804
+ const rv = new RunView();
805
+
806
+ const lastLearningCycleRV = await rv.RunView<AIAgentLearningCycleEntity>({
807
+ EntityName: 'AI Agent Learning Cycles',
808
+ ExtraFilter: `AgentID = '${agentID}' AND Status = 'Complete'`,
809
+ ResultType: 'entity_object',
810
+ OrderBy: 'StartedAt DESC',
811
+ MaxRows: 1,
812
+ }, user);
813
+
814
+ const lastLearningCycle = lastLearningCycleRV.Results[0];
815
+
816
+ if (lastLearningCycle) {
817
+ return lastLearningCycle.StartedAt;
818
+ }
819
+ else {
820
+ // if no lerarning cycle found, return the epoch date
821
+ return new Date(0);
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Builds the chat API request for Skip
827
+ */
828
+ protected async buildSkipChatAPIRequest(
829
+ messages: SkipMessage[],
830
+ conversationId: string,
831
+ dataContext: DataContext,
832
+ requestPhase: SkipRequestPhase,
833
+ includeEntities: boolean,
834
+ includeQueries: boolean,
835
+ includeNotes: boolean,
836
+ includeRequests: boolean,
837
+ contextUser: UserInfo,
838
+ dataSource: DataSource,
839
+ forceEntitiesRefresh: boolean = false,
840
+ includeCallBackKeyAndAccessToken: boolean = false
841
+ ): Promise<SkipAPIRequest> {
842
+ // Additional token info specific to chat requests
843
+ const additionalTokenInfo = {
844
+ conversationId,
845
+ requestPhase,
846
+ };
847
+
848
+ // Get base request data
849
+ const baseRequest = await this.buildBaseSkipRequest(
850
+ contextUser,
851
+ dataSource,
852
+ includeEntities,
853
+ includeQueries,
854
+ includeNotes,
855
+ includeRequests,
856
+ forceEntitiesRefresh,
857
+ includeCallBackKeyAndAccessToken,
858
+ additionalTokenInfo
859
+ );
860
+
861
+ // Create the chat-specific request object
862
+ const input: SkipAPIRequest = {
863
+ messages,
864
+ conversationID: conversationId.toString(),
865
+ dataContext: <DataContext>CopyScalarsAndArrays(dataContext), // we are casting this to DataContext as we're pushing this to the Skip API, and we don't want to send the real DataContext object, just a copy of the scalar and array properties
866
+ organizationID: baseRequest.organizationId,
867
+ requestPhase,
868
+ entities: baseRequest.entities,
869
+ queries: baseRequest.queries,
870
+ notes: baseRequest.notes,
871
+ noteTypes: baseRequest.noteTypes,
872
+ apiKeys: baseRequest.apiKeys,
873
+ };
874
+
281
875
  return input;
282
876
  }
283
877
 
@@ -298,9 +892,9 @@ export class AskSkipResolver {
298
892
  if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
299
893
  const dataContext: DataContext = new DataContext();
300
894
  await dataContext.Load(DataContextId, dataSource, true, false, 0, user);
301
- const input = <SkipAPIRunScriptRequest>await this.buildSkipAPIRequest([], '', dataContext, 'run_existing_script', false, false, false, user, dataSource, false, false);
895
+ const input = <SkipAPIRunScriptRequest>await this.buildSkipChatAPIRequest([], '', dataContext, 'run_existing_script', false, false, false, false, user, dataSource, false, false);
302
896
  input.scriptText = ScriptText;
303
- return this.handleSimpleSkipPostRequest(input);
897
+ return this.handleSimpleSkipChatPostRequest(input);
304
898
  }
305
899
 
306
900
  protected buildSkipAPIKeys(): SkipAPIRequestAPIKey[] {
@@ -341,7 +935,7 @@ export class AskSkipResolver {
341
935
  const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
342
936
  if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
343
937
 
344
- const { convoEntity, dataContextEntity, convoDetailEntity, dataContext } = await this.HandleSkipInitialObjectLoading(
938
+ const { convoEntity, dataContextEntity, convoDetailEntity, dataContext } = await this.HandleSkipChatInitialObjectLoading(
345
939
  dataSource,
346
940
  ConversationId,
347
941
  UserQuestion,
@@ -359,9 +953,9 @@ export class AskSkipResolver {
359
953
  );
360
954
 
361
955
  const conversationDetailCount = 1
362
- const input = await this.buildSkipAPIRequest(messages, ConversationId, dataContext, 'initial_request', true, true, true, user, dataSource, ForceEntityRefresh === undefined ? false : ForceEntityRefresh, true);
956
+ const input = await this.buildSkipChatAPIRequest(messages, ConversationId, dataContext, 'initial_request', true, true, true, false, user, dataSource, ForceEntityRefresh === undefined ? false : ForceEntityRefresh, true);
363
957
 
364
- return this.HandleSkipRequest(
958
+ return this.HandleSkipChatRequest(
365
959
  input,
366
960
  UserQuestion,
367
961
  user,
@@ -440,7 +1034,7 @@ export class AskSkipResolver {
440
1034
  return {
441
1035
  id: r.ID,
442
1036
  agentNoteTypeId: r.AgentNoteTypeID,
443
- agentNoteType: r.AgentNoteType,
1037
+ agentNoteType: r.AgentNoteType,
444
1038
  note: r.Note,
445
1039
  type: r.Type,
446
1040
  userId: r.UserID,
@@ -743,7 +1337,7 @@ export class AskSkipResolver {
743
1337
  }
744
1338
  }
745
1339
 
746
- protected async HandleSkipInitialObjectLoading(
1340
+ protected async HandleSkipChatInitialObjectLoading(
747
1341
  dataSource: DataSource,
748
1342
  ConversationId: string,
749
1343
  UserQuestion: string,
@@ -971,7 +1565,7 @@ export class AskSkipResolver {
971
1565
  }
972
1566
  }
973
1567
 
974
- protected async HandleSkipRequest(
1568
+ protected async HandleSkipChatRequest(
975
1569
  input: SkipAPIRequest,
976
1570
  UserQuestion: string,
977
1571
  user: UserInfo,
@@ -1379,7 +1973,7 @@ export class AskSkipResolver {
1379
1973
  }
1380
1974
  conversationDetailCount++;
1381
1975
  // we have all of the data now, add it to the data context and then submit it back to the Skip API
1382
- return this.HandleSkipRequest(
1976
+ return this.HandleSkipChatRequest(
1383
1977
  apiRequest,
1384
1978
  UserQuestion,
1385
1979
  user,
@@ -1497,12 +2091,154 @@ export class AskSkipResolver {
1497
2091
  };
1498
2092
  }
1499
2093
 
2094
+ protected getAgentNoteTypeIDByName(name: string): string {
2095
+ const noteTypeID = AIEngine.Instance.AgentNoteTypes.find(nt => nt.Name.trim().toLowerCase() === name.trim().toLowerCase())?.ID;
2096
+ if (noteTypeID) {
2097
+ return noteTypeID;
2098
+ }
2099
+ else{
2100
+ // default to AI note ID
2101
+ const AINoteTypeID = AIEngine.Instance.AgentNoteTypes.find(nt => nt.Name.trim().toLowerCase() === 'AI')?.ID;
2102
+ return AINoteTypeID
2103
+ }
2104
+ }
2105
+
1500
2106
  protected async getViewData(ViewId: string, user: UserInfo): Promise<any> {
1501
2107
  const rv = new RunView();
1502
2108
  const result = await rv.RunView({ ViewID: ViewId, IgnoreMaxRows: true }, user);
1503
2109
  if (result && result.Success) return result.Results;
1504
2110
  else throw new Error(`Error running view ${ViewId}`);
1505
2111
  }
2112
+
2113
+ /**
2114
+ * Manually executes the Skip AI learning cycle.
2115
+ * @param OrganizationId Optional organization ID to register for this run
2116
+ */
2117
+ @Mutation(() => ManualLearningCycleResultType)
2118
+ async ManuallyExecuteSkipLearningCycle(
2119
+ @Arg('OrganizationId', () => String, { nullable: true }) OrganizationId?: string
2120
+ ): Promise<ManualLearningCycleResultType> {
2121
+ try {
2122
+ LogStatus('Manual execution of Skip learning cycle requested via API');
2123
+
2124
+ // Use the organization ID from config if not provided
2125
+ const orgId = OrganizationId || ___skipAPIOrgId;
2126
+
2127
+ // Call the scheduler's manual execution method with org ID
2128
+ const result = await LearningCycleScheduler.Instance.manuallyExecuteLearningCycle(orgId);
2129
+
2130
+ return {
2131
+ Success: result,
2132
+ Message: result
2133
+ ? `Learning cycle was successfully executed manually for organization ${orgId}`
2134
+ : `Learning cycle execution failed for organization ${orgId}. Check server logs for details.`
2135
+ };
2136
+ }
2137
+ catch (e) {
2138
+ LogError(`Error in ManuallyExecuteSkipLearningCycle: ${e}`);
2139
+ return {
2140
+ Success: false,
2141
+ Message: `Error executing learning cycle: ${e}`
2142
+ };
2143
+ }
2144
+ }
2145
+
2146
+ /**
2147
+ * Gets the current status of the learning cycle scheduler
2148
+ */
2149
+ @Query(() => LearningCycleStatusType)
2150
+ async GetLearningCycleStatus(): Promise<LearningCycleStatusType> {
2151
+ try {
2152
+ const status = LearningCycleScheduler.Instance.getStatus();
2153
+
2154
+ return {
2155
+ IsSchedulerRunning: status.isSchedulerRunning,
2156
+ LastRunTime: status.lastRunTime ? status.lastRunTime.toISOString() : null,
2157
+ RunningOrganizations: status.runningOrganizations ? status.runningOrganizations.map(org => ({
2158
+ OrganizationId: org.organizationId,
2159
+ LearningCycleId: org.learningCycleId,
2160
+ StartTime: org.startTime.toISOString(),
2161
+ RunningForMinutes: org.runningForMinutes
2162
+ })) : []
2163
+ };
2164
+ }
2165
+ catch (e) {
2166
+ LogError(`Error in GetLearningCycleStatus: ${e}`);
2167
+ return {
2168
+ IsSchedulerRunning: false,
2169
+ LastRunTime: null,
2170
+ RunningOrganizations: []
2171
+ };
2172
+ }
2173
+ }
2174
+
2175
+ /**
2176
+ * Checks if a specific organization is running a learning cycle
2177
+ * @param OrganizationId The organization ID to check
2178
+ */
2179
+ @Query(() => RunningOrganizationType, { nullable: true })
2180
+ async IsOrganizationRunningLearningCycle(
2181
+ @Arg('OrganizationId', () => String) OrganizationId: string
2182
+ ): Promise<RunningOrganizationType | null> {
2183
+ try {
2184
+ // Use the organization ID from config if not provided
2185
+ const orgId = OrganizationId || ___skipAPIOrgId;
2186
+
2187
+ const status = LearningCycleScheduler.Instance.isOrganizationRunningCycle(orgId);
2188
+
2189
+ if (!status.isRunning) {
2190
+ return null;
2191
+ }
2192
+
2193
+ return {
2194
+ OrganizationId: orgId,
2195
+ LearningCycleId: status.learningCycleId,
2196
+ StartTime: status.startTime.toISOString(),
2197
+ RunningForMinutes: status.runningForMinutes
2198
+ };
2199
+ }
2200
+ catch (e) {
2201
+ LogError(`Error in IsOrganizationRunningLearningCycle: ${e}`);
2202
+ return null;
2203
+ }
2204
+ }
2205
+
2206
+ /**
2207
+ * Stops a running learning cycle for a specific organization
2208
+ * @param OrganizationId The organization ID to stop the cycle for
2209
+ */
2210
+ @Mutation(() => StopLearningCycleResultType)
2211
+ async StopLearningCycleForOrganization(
2212
+ @Arg('OrganizationId', () => String) OrganizationId: string
2213
+ ): Promise<StopLearningCycleResultType> {
2214
+ try {
2215
+ // Use the organization ID from config if not provided
2216
+ const orgId = OrganizationId || ___skipAPIOrgId;
2217
+
2218
+ const result = LearningCycleScheduler.Instance.stopLearningCycleForOrganization(orgId);
2219
+
2220
+ // Transform the result to match our GraphQL type
2221
+ return {
2222
+ Success: result.success,
2223
+ Message: result.message,
2224
+ WasRunning: result.wasRunning,
2225
+ CycleDetails: result.cycleDetails ? {
2226
+ LearningCycleId: result.cycleDetails.learningCycleId,
2227
+ StartTime: result.cycleDetails.startTime.toISOString(),
2228
+ RunningForMinutes: result.cycleDetails.runningForMinutes
2229
+ } : null
2230
+ };
2231
+ }
2232
+ catch (e) {
2233
+ LogError(`Error in StopLearningCycleForOrganization: ${e}`);
2234
+ return {
2235
+ Success: false,
2236
+ Message: `Error stopping learning cycle: ${e}`,
2237
+ WasRunning: false,
2238
+ CycleDetails: null
2239
+ };
2240
+ }
2241
+ }
1506
2242
  }
1507
2243
 
1508
2244
  export default AskSkipResolver;