@memberjunction/server 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.eslintignore +4 -4
  2. package/.eslintrc +24 -24
  3. package/CHANGELOG.json +92 -0
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +141 -141
  6. package/dist/config.d.ts +3 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +4 -1
  9. package/dist/config.js.map +1 -1
  10. package/dist/entitySubclasses/entityPermissions.server.d.ts +23 -0
  11. package/dist/entitySubclasses/entityPermissions.server.d.ts.map +1 -0
  12. package/dist/entitySubclasses/entityPermissions.server.js +99 -0
  13. package/dist/entitySubclasses/entityPermissions.server.js.map +1 -0
  14. package/dist/entitySubclasses/userViewEntity.server.js +17 -17
  15. package/dist/generated/generated.d.ts.map +1 -1
  16. package/dist/generated/generated.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/resolvers/AskSkipResolver.js +10 -10
  22. package/dist/resolvers/FileCategoryResolver.js +2 -2
  23. package/dist/resolvers/ReportResolver.js +4 -4
  24. package/package.json +80 -80
  25. package/src/apolloServer/TransactionPlugin.ts +57 -57
  26. package/src/apolloServer/index.ts +33 -33
  27. package/src/auth/exampleNewUserSubClass.ts +73 -73
  28. package/src/auth/index.ts +151 -151
  29. package/src/auth/newUsers.ts +56 -56
  30. package/src/auth/tokenExpiredError.ts +12 -12
  31. package/src/cache.ts +10 -10
  32. package/src/config.ts +89 -84
  33. package/src/context.ts +119 -119
  34. package/src/directives/Public.ts +42 -42
  35. package/src/directives/index.ts +1 -1
  36. package/src/entitySubclasses/entityPermissions.server.ts +111 -0
  37. package/src/entitySubclasses/userViewEntity.server.ts +187 -187
  38. package/src/generated/generated.ts +2573 -2573
  39. package/src/generic/PushStatusResolver.ts +40 -40
  40. package/src/generic/ResolverBase.ts +331 -331
  41. package/src/generic/RunViewResolver.ts +350 -350
  42. package/src/index.ts +133 -137
  43. package/src/orm.ts +36 -36
  44. package/src/resolvers/AskSkipResolver.ts +782 -782
  45. package/src/resolvers/ColorResolver.ts +72 -72
  46. package/src/resolvers/DatasetResolver.ts +115 -115
  47. package/src/resolvers/EntityRecordNameResolver.ts +77 -77
  48. package/src/resolvers/EntityResolver.ts +37 -37
  49. package/src/resolvers/FileCategoryResolver.ts +38 -38
  50. package/src/resolvers/FileResolver.ts +110 -110
  51. package/src/resolvers/MergeRecordsResolver.ts +198 -198
  52. package/src/resolvers/PotentialDuplicateRecordResolver.ts +59 -59
  53. package/src/resolvers/QueryResolver.ts +42 -42
  54. package/src/resolvers/ReportResolver.ts +131 -131
  55. package/src/resolvers/UserFavoriteResolver.ts +102 -102
  56. package/src/resolvers/UserResolver.ts +29 -29
  57. package/src/resolvers/UserViewResolver.ts +64 -64
  58. package/src/types.ts +19 -19
  59. package/src/util.ts +106 -106
  60. package/tsconfig.json +31 -31
  61. package/typedoc.json +4 -4
  62. package/build.log.json +0 -47
@@ -1,782 +1,782 @@
1
- import { Arg, Ctx, Field, Int, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
2
- import { LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
3
- import { AppContext, UserPayload } from '../types';
4
- import { UserCache } from '@memberjunction/sqlserver-dataprovider';
5
- import { DataContext } from '@memberjunction/data-context'
6
- import { LoadDataContextItemsServer } from '@memberjunction/data-context-server';
7
- 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
8
-
9
- import { SkipAPIRequest, SkipAPIResponse, SkipMessage, SkipAPIAnalysisCompleteResponse, SkipAPIDataRequestResponse, SkipAPIClarifyingQuestionResponse, SkipEntityInfo, SkipQueryInfo, SkipAPIRunScriptRequest, SkipAPIRequestAPIKey } from '@memberjunction/skip-types';
10
-
11
- import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver';
12
- import { ConversationDetailEntity, ConversationEntity, DataContextEntity, DataContextItemEntity, UserNotificationEntity } from '@memberjunction/core-entities';
13
- import { DataSource } from 'typeorm';
14
- import { ___skipAPIOrgId, ___skipAPIurl, configInfo, mj_core_schema } from '../config';
15
-
16
-
17
- import { registerEnumType } from "type-graphql";
18
- import { MJGlobal, CopyScalarsAndArrays } from '@memberjunction/global';
19
- import { sendPostRequest } from '../util';
20
- import { GetAIAPIKey } from '@memberjunction/ai';
21
-
22
-
23
- enum SkipResponsePhase {
24
- ClarifyingQuestion = "clarifying_question",
25
- DataRequest = "data_request",
26
- AnalysisComplete = "analysis_complete",
27
- }
28
-
29
- registerEnumType(SkipResponsePhase, {
30
- name: "SkipResponsePhase",
31
- description: "The phase of the respons: clarifying_question, data_request, or analysis_complete",
32
- });
33
-
34
-
35
- @ObjectType()
36
- export class AskSkipResultType {
37
- @Field(() => Boolean)
38
- Success: boolean;
39
-
40
- @Field(() => String)
41
- Status: string; // required
42
-
43
- @Field(() => SkipResponsePhase)
44
- ResponsePhase: SkipResponsePhase;
45
-
46
- @Field(() => String)
47
- Result: string;
48
-
49
- @Field(() => Int)
50
- ConversationId: number;
51
-
52
- @Field(() => Int)
53
- UserMessageConversationDetailId: number;
54
-
55
- @Field(() => Int)
56
- AIMessageConversationDetailId: number;
57
- }
58
-
59
-
60
- @Resolver(AskSkipResultType)
61
- export class AskSkipResolver {
62
- private static _defaultNewChatName = 'New Chat';
63
- private static _maxHistoricalMessages = 20;
64
-
65
- /**
66
- * Executes a script in the context of a data context and returns the results
67
- * @param pubSub
68
- * @param DataContextId
69
- * @param ScriptText
70
- */
71
- @Query(() => AskSkipResultType)
72
- async ExecuteAskSkipRunScript(@Ctx() { dataSource, userPayload }: AppContext,
73
- @PubSub() pubSub: PubSubEngine,
74
- @Arg('DataContextId', () => Int) DataContextId: number,
75
- @Arg('ScriptText', () => String) ScriptText: string) {
76
- const md = new Metadata();
77
- const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
78
- if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
79
- const dataContext: DataContext = new DataContext();
80
- await dataContext.Load(DataContextId, dataSource, true, user);
81
- const input: SkipAPIRunScriptRequest = {
82
- apiKeys: this.buildSkipAPIKeys(),
83
- scriptText: ScriptText,
84
- messages: [], // not needed for this request
85
- conversationID: '', // not needed for this request
86
- 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
87
- organizationID: !isNaN(parseInt(___skipAPIOrgId)) ? parseInt(___skipAPIOrgId) : 0,
88
- requestPhase: 'run_existing_script',
89
- entities: [], // not needed for this request
90
- queries: [], // not needed for this request
91
- };
92
-
93
- LogStatus(` >>> Sending request to Skip API: ${___skipAPIurl}`)
94
-
95
- const response = await sendPostRequest(___skipAPIurl, input, true, null);
96
-
97
- if (response && response.length > 0) {
98
- // the last object in the response array is the final response from the Skip API
99
- const apiResponse = <SkipAPIResponse>response[response.length - 1];
100
- // const apiResponse = <SkipAPIResponse>response.data;
101
- LogStatus(` Skip API response: ${apiResponse.responsePhase}`)
102
- return {
103
- Success: true,
104
- Status: 'OK',
105
- ResponsePhase: SkipResponsePhase.AnalysisComplete,
106
- ConversationId: 0,
107
- UserMessageConversationDetailId: 0,
108
- AIMessageConversationDetailId: 0,
109
- Result: JSON.stringify(apiResponse)
110
- };
111
- }
112
- else {
113
- return {
114
- Success: false,
115
- Status: 'Error',
116
- Result: `Report Refresh failed`,
117
- ResponsePhase: SkipResponsePhase.AnalysisComplete,
118
- ConversationId: 0,
119
- UserMessageConversationDetailId: 0,
120
- AIMessageConversationDetailId: 0,
121
- };
122
- }
123
- }
124
-
125
- protected buildSkipAPIKeys(): SkipAPIRequestAPIKey[] {
126
- return [
127
- {
128
- vendorDriverName: 'OpenAILLM',
129
- apiKey: GetAIAPIKey('OpenAILLM')
130
- },
131
- {
132
- vendorDriverName: 'AnthropicLLM',
133
- apiKey: GetAIAPIKey('AnthropicLLM')
134
- },
135
- {
136
- vendorDriverName: 'GeminiLLM',
137
- apiKey: GetAIAPIKey('GeminiLLM')
138
- },
139
- {
140
- vendorDriverName: 'GroqLLM',
141
- apiKey: GetAIAPIKey('GroqLLM')
142
- }
143
- ];
144
- }
145
-
146
- @Query(() => AskSkipResultType)
147
- async ExecuteAskSkipAnalysisQuery(
148
- @Arg('UserQuestion', () => String) UserQuestion: string,
149
- @Arg('ConversationId', () => Int) ConversationId: number,
150
- @Ctx() { dataSource, userPayload }: AppContext,
151
- @PubSub() pubSub: PubSubEngine,
152
- @Arg('DataContextId', () => Int, { nullable: true }) DataContextId?: number
153
- ) {
154
- const md = new Metadata();
155
- const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
156
- if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
157
-
158
- const {convoEntity, dataContextEntity, convoDetailEntity, dataContext} = await this.HandleSkipInitialObjectLoading(dataSource, ConversationId, UserQuestion, user, userPayload, md, DataContextId);
159
-
160
- // now load up the messages. We will load up ALL of the messages for this conversation, and then pass them to the Skip API
161
- const messages: SkipMessage[] = await this.LoadConversationDetailsIntoSkipMessages(dataSource, convoEntity.ID, AskSkipResolver._maxHistoricalMessages);
162
-
163
- const input: SkipAPIRequest = {
164
- apiKeys: this.buildSkipAPIKeys(),
165
- organizationInfo: configInfo?.askSkip?.organizationInfo,
166
- messages: messages,
167
- conversationID: ConversationId.toString(),
168
- 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
169
- organizationID: !isNaN(parseInt(___skipAPIOrgId)) ? parseInt(___skipAPIOrgId) : 0,
170
- requestPhase: 'initial_request',
171
- entities: this.BuildSkipEntities(),
172
- queries: this.BuildSkipQueries(),
173
- };
174
-
175
- return this.HandleSkipRequest(input, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, md, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
176
- }
177
-
178
- protected BuildSkipQueries(): SkipQueryInfo[] {
179
- const md = new Metadata();
180
- return md.Queries.map((q) => {
181
- return {
182
- id: q.ID,
183
- name: q.Name,
184
- description: q.Description,
185
- category: q.Category,
186
- sql: q.SQL,
187
- originalSQL: q.OriginalSQL,
188
- feedback: q.Feedback,
189
- status: q.Status,
190
- qualityRank: q.QualityRank,
191
- createdAt: q.CreatedAt,
192
- updatedAt: q.UpdatedAt,
193
- categoryID: q.CategoryID,
194
- fields: q.Fields.map((f) => {
195
- return {
196
- id: f.ID,
197
- queryID: f.QueryID,
198
- sequence: f.Sequence,
199
- name: f.Name,
200
- description: f.Description,
201
- sqlBaseType: f.SQLBaseType,
202
- sqlFullType: f.SQLFullType,
203
- sourceEntityID: f.SourceEntityID,
204
- sourceEntity: f.SourceEntity,
205
- sourceFieldName: f.SourceFieldName,
206
- isComputed: f.IsComputed,
207
- computationDescription: f.ComputationDescription,
208
- isSummary: f.IsSummary,
209
- summaryDescription: f.SummaryDescription,
210
- createdAt: f.CreatedAt,
211
- updatedAt: f.UpdatedAt,
212
- }
213
- })
214
- }
215
- }
216
- );
217
- }
218
-
219
- protected BuildSkipEntities(): SkipEntityInfo[] {
220
- // build the entity info for skip in its format which is
221
- // narrower in scope than our native MJ metadata
222
- // don't pass the mj_core_schema entities
223
- const md = new Metadata();
224
- return md.Entities.filter(e => e.SchemaName !== mj_core_schema ).map((e) => {
225
- const ret: SkipEntityInfo = {
226
- id: e.ID,
227
- name: e.Name,
228
- schemaName: e.SchemaName,
229
- baseView: e.BaseView,
230
- description: e.Description,
231
- fields: e.Fields.map((f) => {
232
- return {
233
- id: f.ID,
234
- entityID: f.EntityID,
235
- sequence: f.Sequence,
236
- name: f.Name,
237
- displayName: f.DisplayName,
238
- category: f.Category,
239
- type: f.Type,
240
- description: f.Description,
241
- isPrimaryKey: f.IsPrimaryKey,
242
- allowsNull: f.AllowsNull,
243
- isUnique: f.IsUnique,
244
- length: f.Length,
245
- precision: f.Precision,
246
- scale: f.Scale,
247
- sqlFullType: f.SQLFullType,
248
- defaultValue: f.DefaultValue,
249
- autoIncrement: f.AutoIncrement,
250
- valueListType: f.ValueListType,
251
- extendedType: f.ExtendedType,
252
- defaultInView: f.DefaultInView,
253
- defaultColumnWidth: f.DefaultColumnWidth,
254
- isVirtual: f.IsVirtual,
255
- isNameField: f.IsNameField,
256
- relatedEntityID: f.RelatedEntityID,
257
- relatedEntityFieldName: f.RelatedEntityFieldName,
258
- relatedEntity: f.RelatedEntity,
259
- relatedEntitySchemaName: f.RelatedEntitySchemaName,
260
- relatedEntityBaseView: f.RelatedEntityBaseView,
261
- };
262
- }
263
- ),
264
- relatedEntities: e.RelatedEntities.map((r) => {
265
- return {
266
- entityID: r.EntityID,
267
- relatedEntityID: r.RelatedEntityID,
268
- type: r.Type,
269
- entityKeyField: r.EntityKeyField,
270
- relatedEntityJoinField: r.RelatedEntityJoinField,
271
- joinView: r.JoinView,
272
- joinEntityJoinField: r.JoinEntityJoinField,
273
- joinEntityInverseJoinField: r.JoinEntityInverseJoinField,
274
- entity: r.Entity,
275
- entityBaseView: r.EntityBaseView,
276
- relatedEntity: r.RelatedEntity,
277
- relatedEntityBaseView: r.RelatedEntityBaseView,
278
- }
279
- })
280
- };
281
- return ret;
282
- });
283
- }
284
-
285
- protected async HandleSkipInitialObjectLoading(dataSource: DataSource,
286
- ConversationId: number,
287
- UserQuestion: string,
288
- user: UserInfo,
289
- userPayload: UserPayload,
290
- md: Metadata,
291
- DataContextId: number): Promise<{convoEntity: ConversationEntity,
292
- dataContextEntity: DataContextEntity,
293
- convoDetailEntity: ConversationDetailEntity,
294
- dataContext: DataContext}> {
295
- const convoEntity = <ConversationEntity>await md.GetEntityObject('Conversations', user);
296
- let dataContextEntity: DataContextEntity;
297
-
298
- if (!ConversationId || ConversationId <= 0) {
299
- // create a new conversation id
300
- convoEntity.NewRecord();
301
- if (user) {
302
- convoEntity.UserID = user.ID;
303
- convoEntity.Name = AskSkipResolver._defaultNewChatName;
304
-
305
- dataContextEntity = await md.GetEntityObject<DataContextEntity>('Data Contexts', user);
306
- if (!DataContextId || DataContextId <= 0) {
307
- dataContextEntity.NewRecord();
308
- dataContextEntity.UserID = user.ID;
309
- dataContextEntity.Name = 'Data Context for Skip Conversation';
310
- if (!await dataContextEntity.Save())
311
- throw new Error(`Creating a new data context failed`);
312
- }
313
- else {
314
- await dataContextEntity.Load(DataContextId);
315
- }
316
- convoEntity.DataContextID = dataContextEntity.ID;
317
- if (await convoEntity.Save()) {
318
- ConversationId = convoEntity.ID;
319
- if (!DataContextId || dataContextEntity.ID <= 0) {
320
- // only do this if we created a new data context for this conversation
321
- dataContextEntity.Name += ` ${ConversationId}`;
322
- await dataContextEntity.Save();
323
- }
324
- }
325
- else
326
- throw new Error(`Creating a new conversation failed`);
327
- }
328
- else {
329
- throw new Error(`User ${userPayload.email} not found in UserCache`);
330
- }
331
- } else {
332
- await convoEntity.Load(ConversationId); // load the existing conversation, will need it later
333
- dataContextEntity = await md.GetEntityObject<DataContextEntity>('Data Contexts', user);
334
-
335
- // note - we ignore the parameter DataContextId if it is passed in, we will use the data context from the conversation that is saved. If a user wants to change the data context for a convo, they can do that elsewhere
336
- if (DataContextId && DataContextId > 0 && DataContextId !== convoEntity.DataContextID) {
337
- if (convoEntity.DataContextID === null) {
338
- convoEntity.DataContextID = DataContextId;
339
- await convoEntity.Save();
340
- }
341
- else
342
- console.warn(`AskSkipResolver: DataContextId ${DataContextId} was passed in but it was ignored because it was different than the DataContextID in the conversation ${convoEntity.DataContextID}`);
343
- }
344
-
345
- await dataContextEntity.Load(convoEntity.DataContextID);
346
- }
347
-
348
-
349
- // now, create a conversation detail record for the user message
350
- const convoDetailEntity = await md.GetEntityObject<ConversationDetailEntity>('Conversation Details', user);
351
- convoDetailEntity.NewRecord();
352
- convoDetailEntity.ConversationID = ConversationId;
353
- convoDetailEntity.Message = UserQuestion;
354
- convoDetailEntity.Role = 'User';
355
- convoDetailEntity.Set('Sequence', 1); // using weakly typed here because we're going to get rid of this field soon
356
- await convoDetailEntity.Save();
357
-
358
- const dataContext = MJGlobal.Instance.ClassFactory.CreateInstance<DataContext>(DataContext); // await this.LoadDataContext(md, dataSource, dataContextEntity, user, false);
359
- await dataContext.Load(dataContextEntity.ID, dataSource, false, user);
360
- return {dataContext, convoEntity, dataContextEntity, convoDetailEntity};
361
- }
362
-
363
- protected async LoadConversationDetailsIntoSkipMessages(dataSource: DataSource, ConversationId: number, maxHistoricalMessages?: number): Promise<SkipMessage[]> {
364
- try {
365
- // load up all the conversation details from the database server
366
- const md = new Metadata();
367
- const e = md.Entities.find((e) => e.Name === 'Conversation Details');
368
- const sql = `SELECT
369
- ${maxHistoricalMessages ? 'TOP ' + maxHistoricalMessages : ''} ID, Message, Role, CreatedAt
370
- FROM
371
- ${e.SchemaName}.${e.BaseView}
372
- WHERE
373
- ConversationID = ${ConversationId}
374
- ORDER
375
- BY CreatedAt DESC`;
376
- const result = await dataSource.query(sql);
377
- if (!result)
378
- throw new Error(`Error running SQL: ${sql}`);
379
- else {
380
- // first, let's sort the result array into a local variable called returnData and in that we will sort by CreatedAt in ASCENDING order so we have the right chronological order
381
- // the reason we're doing a LOCAL sort here is because in the SQL query above, we're sorting in DESCENDING order so we can use the TOP clause to limit the number of records and get the
382
- // N most recent records. We want to sort in ASCENDING order because we want to send the messages to the Skip API in the order they were created.
383
- const returnData = result.sort((a: any, b: any) => {
384
- const aDate = new Date(a.CreatedAt);
385
- const bDate = new Date(b.CreatedAt);
386
- return aDate.getTime() - bDate.getTime();
387
- });
388
-
389
- // now, we will map the returnData into an array of SkipMessages
390
- return returnData.map((r: ConversationDetailEntity) => {
391
- // we want to limit the # of characters in the message to 5000, rough approximation for 1000 words/tokens
392
- // but we only do that for system messages
393
- const skipRole = this.MapDBRoleToSkipRole(r.Role);
394
- let outputMessage; // will be populated below for system messages
395
- if (skipRole === 'system') {
396
- const detail = <SkipAPIResponse>JSON.parse(r.Message);
397
- if (detail.responsePhase === SkipResponsePhase.AnalysisComplete) {
398
- const analysisDetail = <SkipAPIAnalysisCompleteResponse>detail;
399
- outputMessage = JSON.stringify({
400
- responsePhase: SkipResponsePhase.AnalysisComplete,
401
- techExplanation: analysisDetail.techExplanation,
402
- userExplanation: analysisDetail.userExplanation,
403
- scriptText: analysisDetail.scriptText,
404
- tableDataColumns: analysisDetail.tableDataColumns
405
- });
406
- }
407
- else if (detail.responsePhase === SkipResponsePhase.ClarifyingQuestion) {
408
- const clarifyingQuestionDetail = <SkipAPIClarifyingQuestionResponse>detail;
409
- outputMessage = JSON.stringify({
410
- responsePhase: SkipResponsePhase.ClarifyingQuestion,
411
- clarifyingQuestion: clarifyingQuestionDetail.clarifyingQuestion
412
- });
413
- }
414
- else {
415
- // we should never get here, AI responses only fit the above
416
- // don't throw an exception, but log an error
417
- LogError(`Unknown response phase: ${detail.responsePhase}`);
418
- }
419
- }
420
- const m: SkipMessage = {
421
- content: skipRole === 'system' ? outputMessage : r.Message,
422
- role: skipRole,
423
- };
424
- return m;
425
- });
426
- }
427
- }
428
- catch (e) {
429
- LogError(e);
430
- throw e;
431
- }
432
- }
433
-
434
- protected MapDBRoleToSkipRole(role: string): "user" | "system" {
435
- switch (role.trim().toLowerCase()) {
436
- case 'ai':
437
- case 'system':
438
- case 'assistant':
439
- return 'system';
440
- default:
441
- return 'user';
442
- }
443
- }
444
-
445
- protected async HandleSkipRequest(input: SkipAPIRequest, UserQuestion: string, user: UserInfo, dataSource: DataSource,
446
- ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, md: Metadata,
447
- convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
448
- dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
449
- LogStatus(` >>> Sending request to Skip API: ${___skipAPIurl}`)
450
-
451
- const response = await sendPostRequest(___skipAPIurl, input, true, null, (message) => {
452
- if (message.type==='status_update')
453
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
454
- message: JSON.stringify({
455
- type: 'AskSkip',
456
- status: 'OK',
457
- message: message.value.messages[0].content
458
- }),
459
- sessionId: userPayload.sessionId,
460
- });
461
- });
462
-
463
- if (response && response.length > 0) { // response.status === 200) {
464
- // the last object in the response array is the final response from the Skip API
465
- const apiResponse = <SkipAPIResponse>response[response.length - 1].value;
466
- //const apiResponse = <SkipAPIResponse>response.data;
467
- LogStatus(` Skip API response: ${apiResponse.responsePhase}`)
468
- this.PublishApiResponseUserUpdateMessage(apiResponse, userPayload, pubSub);
469
-
470
- // now, based on the result type, we will either wait for the next phase or we will process the results
471
- if (apiResponse.responsePhase === 'data_request') {
472
- return await this.HandleDataRequestPhase(input, <SkipAPIDataRequestResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
473
- }
474
- else if (apiResponse.responsePhase === 'clarifying_question') {
475
- // need to send the request back to the user for a clarifying question
476
- return await this.HandleClarifyingQuestionPhase(input, <SkipAPIClarifyingQuestionResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity);
477
- }
478
- else if (apiResponse.responsePhase === 'analysis_complete') {
479
- return await this.HandleAnalysisComplete(input, <SkipAPIAnalysisCompleteResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
480
- }
481
- else {
482
- // unknown response phase
483
- throw new Error(`Unknown Skip API response phase: ${apiResponse.responsePhase}`);
484
- }
485
- }
486
- else {
487
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
488
- message: JSON.stringify({
489
- type: 'AskSkip',
490
- status: 'Error',
491
- message: 'Analysis failed to run, please try again later and if this continues, contact your support desk.',
492
- }),
493
- sessionId: userPayload.sessionId,
494
- });
495
-
496
- return {
497
- Success: false,
498
- Status: 'Error',
499
- Result: `User Question ${UserQuestion} didn't work!`,
500
- ResponsePhase: SkipResponsePhase.AnalysisComplete,
501
- ConversationId: ConversationId,
502
- UserMessageConversationDetailId: 0,
503
- AIMessageConversationDetailId: 0,
504
- };
505
- }
506
- }
507
-
508
- protected async PublishApiResponseUserUpdateMessage(apiResponse: SkipAPIResponse, userPayload: UserPayload, pubSub: PubSubEngine) {
509
- let sUserMessage: string = '';
510
- switch (apiResponse.responsePhase) {
511
- case 'data_request':
512
- sUserMessage = 'We need to gather some more data, I will do that next and update you soon.';
513
- break;
514
- case 'analysis_complete':
515
- sUserMessage = 'I have completed the analysis, the results will be available momentarily.';
516
- break;
517
- case 'clarifying_question':
518
- // don't send an update because the actual message will happen and show up in the UI, so this is redundant
519
- //sUserMessage = 'I have a clarifying question for you, please see review our chat so you can provide me a little more info.';
520
- break;
521
- }
522
-
523
- // update the UI
524
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
525
- message: JSON.stringify({
526
- type: 'AskSkip',
527
- status: 'OK',
528
- message: sUserMessage,
529
- }),
530
- sessionId: userPayload.sessionId,
531
- });
532
- }
533
-
534
- protected async HandleAnalysisComplete(apiRequest: SkipAPIRequest, apiResponse: SkipAPIAnalysisCompleteResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
535
- ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
536
- dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
537
- // analysis is complete
538
- // all done, wrap things up
539
- const md = new Metadata();
540
- const {AIMessageConversationDetailID} = await this.FinishConversationAndNotifyUser(apiResponse, dataContext, dataContextEntity, md, user, convoEntity, pubSub, userPayload);
541
-
542
- return {
543
- Success: true,
544
- Status: 'OK',
545
- ResponsePhase: SkipResponsePhase.AnalysisComplete,
546
- ConversationId: ConversationId,
547
- UserMessageConversationDetailId: convoDetailEntity.ID,
548
- AIMessageConversationDetailId: AIMessageConversationDetailID,
549
- Result: JSON.stringify(apiResponse)
550
- };
551
- }
552
-
553
- protected async HandleClarifyingQuestionPhase(apiRequest: SkipAPIRequest, apiResponse: SkipAPIClarifyingQuestionResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
554
- ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity): Promise<AskSkipResultType> {
555
- // need to create a message here in the COnversation and then pass that id below
556
- const md = new Metadata();
557
- const convoDetailEntityAI = <ConversationDetailEntity>await md.GetEntityObject('Conversation Details', user);
558
- convoDetailEntityAI.NewRecord();
559
- convoDetailEntityAI.ConversationID = ConversationId;
560
- convoDetailEntityAI.Message = JSON.stringify(apiResponse);//.clarifyingQuestion;
561
- convoDetailEntityAI.Role = 'AI';
562
- if (await convoDetailEntityAI.Save()) {
563
- return {
564
- Success: true,
565
- Status: 'OK',
566
- ResponsePhase: SkipResponsePhase.ClarifyingQuestion,
567
- ConversationId: ConversationId,
568
- UserMessageConversationDetailId: convoDetailEntity.ID,
569
- AIMessageConversationDetailId: convoDetailEntityAI.ID,
570
- Result: JSON.stringify(apiResponse)
571
- };
572
- }
573
- else {
574
- return {
575
- Success: false,
576
- Status: 'Error',
577
- ResponsePhase: SkipResponsePhase.ClarifyingQuestion,
578
- ConversationId: ConversationId,
579
- UserMessageConversationDetailId: convoDetailEntity.ID,
580
- AIMessageConversationDetailId: convoDetailEntityAI.ID,
581
- Result: JSON.stringify(apiResponse)
582
- };
583
- }
584
- }
585
-
586
- protected async HandleDataRequestPhase(apiRequest: SkipAPIRequest, apiResponse: SkipAPIDataRequestResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
587
- ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
588
- dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
589
- // our job in this method is to go through each of the data requests from the Skip API, get the data, and then go back to the Skip API again and to the next phase
590
- try {
591
- if (!apiResponse.success) {
592
- LogError(`Data request/gathering from Skip API failed: ${apiResponse.error}`);
593
- return {
594
- Success: false,
595
- Status: `The Skip API Server data gathering phase returned a non-recoverable error. Try again later and Skip might be able to handle this request.\n${apiResponse.error}`,
596
- ResponsePhase: SkipResponsePhase.DataRequest,
597
- ConversationId: ConversationId,
598
- UserMessageConversationDetailId: convoDetailEntity.ID,
599
- AIMessageConversationDetailId: 0,
600
- Result: JSON.stringify(apiResponse)
601
- };
602
- }
603
-
604
- const _maxDataGatheringRetries = 5;
605
- const _dataGatheringFailureHeaderMessage = '***DATA GATHERING FAILURE***';
606
- const md = new Metadata();
607
- const executionErrors = [];
608
- let dataRequest = apiResponse.dataRequest;
609
-
610
- // first, in this situation we want to add a message to our apiRequest so that it is part of the message history with the server
611
- apiRequest.messages.push({
612
- content: `Skip API Requested Data as shown below
613
- ${JSON.stringify(apiResponse.dataRequest)}`,
614
- role: 'system' // user role of system because this came from Skip, we are simplifying the message for the next round if we need to send it back
615
- });
616
-
617
- // check to see if apiResponse.dataRequest is an array, if not, see if it is a single item, and if not, then throw an error
618
- if (!Array.isArray(dataRequest)) {
619
- if (dataRequest) {
620
- dataRequest = [dataRequest];
621
- }
622
- else {
623
- const errorMessage = `Data request from Skip API is not an array and not a single item.`;
624
- LogError(errorMessage);
625
- executionErrors.push({dataRequest: apiResponse.dataRequest, errorMessage: errorMessage});
626
- dataRequest = []; // make a blank array so we can continue
627
- }
628
- }
629
-
630
- for (const dr of dataRequest) {
631
- try {
632
- const item = dataContext.AddDataContextItem();
633
- switch (dr.type) {
634
- case "sql":
635
- item.Type = 'sql';
636
- item.SQL = dr.text;
637
- item.AdditionalDescription = dr.description;
638
- if (!await item.LoadData(dataSource, false, user))
639
- throw new Error(`SQL data request failed: ${item.DataLoadingError}`);
640
- break;
641
- case "stored_query":
642
- const queryName = dr.text;
643
- const query = md.Queries.find((q) => q.Name === queryName);
644
- if (query) {
645
- item.Type = 'query';
646
- item.QueryID = query.ID;
647
- item.RecordName = query.Name;
648
- item.AdditionalDescription = dr.description;
649
- if (!await item.LoadData(dataSource, false, user))
650
- throw new Error(`SQL data request failed: ${item.DataLoadingError}`);
651
- }
652
- else
653
- throw new Error(`Query ${queryName} not found.`);
654
- break;
655
- default:
656
- throw new Error(`Unknown data request type: ${dr.type}`);
657
- break;
658
- }
659
- }
660
- catch (e) {
661
- LogError(e);
662
- executionErrors.push({dataRequest: dr, errorMessage: e && e.message ? e.message : e.toString()});
663
- }
664
- }
665
-
666
- if (executionErrors.length > 0) {
667
- const dataGatheringFailedAttemptCount = apiRequest.messages.filter((m) => m.content.includes(_dataGatheringFailureHeaderMessage)).length + 1;
668
- if (dataGatheringFailedAttemptCount > _maxDataGatheringRetries) {
669
- // we have exceeded the max retries, so in this case we do NOT go back to Skip, instead we just send the errors back to the user
670
- LogStatus(`Execution errors for Skip data request occured, and we have exceeded the max retries${_maxDataGatheringRetries}, sending errors back to the user.`);
671
- return {
672
- Success: false,
673
- Status: 'Error gathering data and we have exceedded the max retries. Try again later and Skip might be able to handle this request.',
674
- ResponsePhase: SkipResponsePhase.DataRequest,
675
- ConversationId: ConversationId,
676
- UserMessageConversationDetailId: convoDetailEntity.ID,
677
- AIMessageConversationDetailId: 0,
678
- Result: JSON.stringify(apiResponse)
679
- };
680
- }
681
- else {
682
- LogStatus(`Execution errors for Skip data request occured, sending those errors back to the Skip API to get new instructions.`);
683
- apiRequest.requestPhase = 'data_gathering_failure';
684
- apiRequest.messages.push({
685
- content: `${_dataGatheringFailureHeaderMessage} #${dataGatheringFailedAttemptCount} of ${_maxDataGatheringRetries} attempts to gather data failed. Errors:
686
- ${JSON.stringify(executionErrors)}
687
- `,
688
- role: 'user' // use user role becuase to the Skip API what we send it is "user"
689
- });
690
- }
691
- }
692
- else {
693
- await dataContext.SaveItems(user, false); // save the data context items
694
- // replace the data context copy that is in the apiRequest.
695
- apiRequest.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
696
- apiRequest.requestPhase = 'data_gathering_response';
697
- }
698
- // we have all of the data now, add it to the data context and then submit it back to the Skip API
699
- return this.HandleSkipRequest(apiRequest, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, md, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
700
- }
701
- catch (e) {
702
- LogError(e);
703
- throw e;
704
- }
705
- }
706
-
707
- /**
708
- * This method will handle the process for an end of successful request where a user is notified of an AI message. The AI message is either the finished report or a clarifying question.
709
- * @param apiResponse
710
- * @param md
711
- * @param user
712
- * @param convoEntity
713
- * @param pubSub
714
- * @param userPayload
715
- * @returns
716
- */
717
- protected async FinishConversationAndNotifyUser(apiResponse: SkipAPIAnalysisCompleteResponse, dataContext: DataContext, dataContextEntity: DataContextEntity, md: Metadata, user: UserInfo, convoEntity: ConversationEntity, pubSub: PubSubEngine, userPayload: UserPayload): Promise<{AIMessageConversationDetailID: number}> {
718
- const sTitle = apiResponse.reportTitle;
719
- const sResult = JSON.stringify(apiResponse);
720
-
721
- // Create a conversation detail record for the Skip response
722
- const convoDetailEntityAI = <ConversationDetailEntity>await md.GetEntityObject('Conversation Details', user);
723
- convoDetailEntityAI.NewRecord();
724
- convoDetailEntityAI.ConversationID = convoEntity.ID;
725
- convoDetailEntityAI.Message = sResult;
726
- convoDetailEntityAI.Role = 'AI';
727
- convoDetailEntityAI.Set('Sequence', 2); // using weakly typed here because we're going to get rid of this field soon
728
- await convoDetailEntityAI.Save();
729
-
730
- // finally update the convo name if it is still the default
731
- if (convoEntity.Name === AskSkipResolver._defaultNewChatName && sTitle && sTitle !== AskSkipResolver._defaultNewChatName) {
732
- convoEntity.Name = sTitle; // use the title from the response
733
- await convoEntity.Save();
734
- }
735
-
736
- // now create a notification for the user
737
- const userNotification = <UserNotificationEntity>await md.GetEntityObject('User Notifications', user);
738
- userNotification.NewRecord();
739
- userNotification.UserID = user.ID;
740
- userNotification.Title = 'Report Created: ' + sTitle;
741
- userNotification.Message = `Good news! Skip finished creating a report for you, click on this notification to jump back into the conversation.`;
742
- userNotification.Unread = true;
743
- userNotification.ResourceConfiguration = JSON.stringify({
744
- type: 'askskip',
745
- conversationId: convoEntity.ID,
746
- });
747
- await userNotification.Save();
748
-
749
- // Save the data context items...
750
- // FOR NOW, we don't want to store the data in the database, we will just load it from the data context when we need it
751
- // we need a better strategy to persist because the cost of storage and retrieval/parsing is higher than just running the query again in many/most cases
752
- dataContext.SaveItems(user, false);
753
-
754
- // send a UI update trhough pub-sub
755
- pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
756
- message: JSON.stringify({
757
- type: 'UserNotifications',
758
- status: 'OK',
759
- details: {
760
- action: 'create',
761
- recordId: userNotification.ID,
762
- },
763
- }),
764
- sessionId: userPayload.sessionId,
765
- });
766
-
767
- return {
768
- AIMessageConversationDetailID: convoDetailEntityAI.ID
769
- };
770
- }
771
-
772
- protected async getViewData(ViewId: number, user: UserInfo): Promise<any> {
773
- const rv = new RunView();
774
- const result = await rv.RunView({ViewID: ViewId, IgnoreMaxRows: true}, user);
775
- if (result && result.Success)
776
- return result.Results;
777
- else
778
- throw new Error(`Error running view ${ViewId}`);
779
- }
780
- }
781
-
782
- export default AskSkipResolver;
1
+ import { Arg, Ctx, Field, Int, ObjectType, PubSub, PubSubEngine, Query, Resolver } from 'type-graphql';
2
+ import { LogError, LogStatus, Metadata, RunView, UserInfo } from '@memberjunction/core';
3
+ import { AppContext, UserPayload } from '../types';
4
+ import { UserCache } from '@memberjunction/sqlserver-dataprovider';
5
+ import { DataContext } from '@memberjunction/data-context'
6
+ import { LoadDataContextItemsServer } from '@memberjunction/data-context-server';
7
+ 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
8
+
9
+ import { SkipAPIRequest, SkipAPIResponse, SkipMessage, SkipAPIAnalysisCompleteResponse, SkipAPIDataRequestResponse, SkipAPIClarifyingQuestionResponse, SkipEntityInfo, SkipQueryInfo, SkipAPIRunScriptRequest, SkipAPIRequestAPIKey } from '@memberjunction/skip-types';
10
+
11
+ import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver';
12
+ import { ConversationDetailEntity, ConversationEntity, DataContextEntity, DataContextItemEntity, UserNotificationEntity } from '@memberjunction/core-entities';
13
+ import { DataSource } from 'typeorm';
14
+ import { ___skipAPIOrgId, ___skipAPIurl, configInfo, mj_core_schema } from '../config';
15
+
16
+
17
+ import { registerEnumType } from "type-graphql";
18
+ import { MJGlobal, CopyScalarsAndArrays } from '@memberjunction/global';
19
+ import { sendPostRequest } from '../util';
20
+ import { GetAIAPIKey } from '@memberjunction/ai';
21
+
22
+
23
+ enum SkipResponsePhase {
24
+ ClarifyingQuestion = "clarifying_question",
25
+ DataRequest = "data_request",
26
+ AnalysisComplete = "analysis_complete",
27
+ }
28
+
29
+ registerEnumType(SkipResponsePhase, {
30
+ name: "SkipResponsePhase",
31
+ description: "The phase of the respons: clarifying_question, data_request, or analysis_complete",
32
+ });
33
+
34
+
35
+ @ObjectType()
36
+ export class AskSkipResultType {
37
+ @Field(() => Boolean)
38
+ Success: boolean;
39
+
40
+ @Field(() => String)
41
+ Status: string; // required
42
+
43
+ @Field(() => SkipResponsePhase)
44
+ ResponsePhase: SkipResponsePhase;
45
+
46
+ @Field(() => String)
47
+ Result: string;
48
+
49
+ @Field(() => Int)
50
+ ConversationId: number;
51
+
52
+ @Field(() => Int)
53
+ UserMessageConversationDetailId: number;
54
+
55
+ @Field(() => Int)
56
+ AIMessageConversationDetailId: number;
57
+ }
58
+
59
+
60
+ @Resolver(AskSkipResultType)
61
+ export class AskSkipResolver {
62
+ private static _defaultNewChatName = 'New Chat';
63
+ private static _maxHistoricalMessages = 20;
64
+
65
+ /**
66
+ * Executes a script in the context of a data context and returns the results
67
+ * @param pubSub
68
+ * @param DataContextId
69
+ * @param ScriptText
70
+ */
71
+ @Query(() => AskSkipResultType)
72
+ async ExecuteAskSkipRunScript(@Ctx() { dataSource, userPayload }: AppContext,
73
+ @PubSub() pubSub: PubSubEngine,
74
+ @Arg('DataContextId', () => Int) DataContextId: number,
75
+ @Arg('ScriptText', () => String) ScriptText: string) {
76
+ const md = new Metadata();
77
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
78
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
79
+ const dataContext: DataContext = new DataContext();
80
+ await dataContext.Load(DataContextId, dataSource, true, user);
81
+ const input: SkipAPIRunScriptRequest = {
82
+ apiKeys: this.buildSkipAPIKeys(),
83
+ scriptText: ScriptText,
84
+ messages: [], // not needed for this request
85
+ conversationID: '', // not needed for this request
86
+ 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
87
+ organizationID: !isNaN(parseInt(___skipAPIOrgId)) ? parseInt(___skipAPIOrgId) : 0,
88
+ requestPhase: 'run_existing_script',
89
+ entities: [], // not needed for this request
90
+ queries: [], // not needed for this request
91
+ };
92
+
93
+ LogStatus(` >>> Sending request to Skip API: ${___skipAPIurl}`)
94
+
95
+ const response = await sendPostRequest(___skipAPIurl, input, true, null);
96
+
97
+ if (response && response.length > 0) {
98
+ // the last object in the response array is the final response from the Skip API
99
+ const apiResponse = <SkipAPIResponse>response[response.length - 1];
100
+ // const apiResponse = <SkipAPIResponse>response.data;
101
+ LogStatus(` Skip API response: ${apiResponse.responsePhase}`)
102
+ return {
103
+ Success: true,
104
+ Status: 'OK',
105
+ ResponsePhase: SkipResponsePhase.AnalysisComplete,
106
+ ConversationId: 0,
107
+ UserMessageConversationDetailId: 0,
108
+ AIMessageConversationDetailId: 0,
109
+ Result: JSON.stringify(apiResponse)
110
+ };
111
+ }
112
+ else {
113
+ return {
114
+ Success: false,
115
+ Status: 'Error',
116
+ Result: `Report Refresh failed`,
117
+ ResponsePhase: SkipResponsePhase.AnalysisComplete,
118
+ ConversationId: 0,
119
+ UserMessageConversationDetailId: 0,
120
+ AIMessageConversationDetailId: 0,
121
+ };
122
+ }
123
+ }
124
+
125
+ protected buildSkipAPIKeys(): SkipAPIRequestAPIKey[] {
126
+ return [
127
+ {
128
+ vendorDriverName: 'OpenAILLM',
129
+ apiKey: GetAIAPIKey('OpenAILLM')
130
+ },
131
+ {
132
+ vendorDriverName: 'AnthropicLLM',
133
+ apiKey: GetAIAPIKey('AnthropicLLM')
134
+ },
135
+ {
136
+ vendorDriverName: 'GeminiLLM',
137
+ apiKey: GetAIAPIKey('GeminiLLM')
138
+ },
139
+ {
140
+ vendorDriverName: 'GroqLLM',
141
+ apiKey: GetAIAPIKey('GroqLLM')
142
+ }
143
+ ];
144
+ }
145
+
146
+ @Query(() => AskSkipResultType)
147
+ async ExecuteAskSkipAnalysisQuery(
148
+ @Arg('UserQuestion', () => String) UserQuestion: string,
149
+ @Arg('ConversationId', () => Int) ConversationId: number,
150
+ @Ctx() { dataSource, userPayload }: AppContext,
151
+ @PubSub() pubSub: PubSubEngine,
152
+ @Arg('DataContextId', () => Int, { nullable: true }) DataContextId?: number
153
+ ) {
154
+ const md = new Metadata();
155
+ const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
156
+ if (!user) throw new Error(`User ${userPayload.email} not found in UserCache`);
157
+
158
+ const {convoEntity, dataContextEntity, convoDetailEntity, dataContext} = await this.HandleSkipInitialObjectLoading(dataSource, ConversationId, UserQuestion, user, userPayload, md, DataContextId);
159
+
160
+ // now load up the messages. We will load up ALL of the messages for this conversation, and then pass them to the Skip API
161
+ const messages: SkipMessage[] = await this.LoadConversationDetailsIntoSkipMessages(dataSource, convoEntity.ID, AskSkipResolver._maxHistoricalMessages);
162
+
163
+ const input: SkipAPIRequest = {
164
+ apiKeys: this.buildSkipAPIKeys(),
165
+ organizationInfo: configInfo?.askSkip?.organizationInfo,
166
+ messages: messages,
167
+ conversationID: ConversationId.toString(),
168
+ 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
169
+ organizationID: !isNaN(parseInt(___skipAPIOrgId)) ? parseInt(___skipAPIOrgId) : 0,
170
+ requestPhase: 'initial_request',
171
+ entities: this.BuildSkipEntities(),
172
+ queries: this.BuildSkipQueries(),
173
+ };
174
+
175
+ return this.HandleSkipRequest(input, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, md, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
176
+ }
177
+
178
+ protected BuildSkipQueries(): SkipQueryInfo[] {
179
+ const md = new Metadata();
180
+ return md.Queries.map((q) => {
181
+ return {
182
+ id: q.ID,
183
+ name: q.Name,
184
+ description: q.Description,
185
+ category: q.Category,
186
+ sql: q.SQL,
187
+ originalSQL: q.OriginalSQL,
188
+ feedback: q.Feedback,
189
+ status: q.Status,
190
+ qualityRank: q.QualityRank,
191
+ createdAt: q.CreatedAt,
192
+ updatedAt: q.UpdatedAt,
193
+ categoryID: q.CategoryID,
194
+ fields: q.Fields.map((f) => {
195
+ return {
196
+ id: f.ID,
197
+ queryID: f.QueryID,
198
+ sequence: f.Sequence,
199
+ name: f.Name,
200
+ description: f.Description,
201
+ sqlBaseType: f.SQLBaseType,
202
+ sqlFullType: f.SQLFullType,
203
+ sourceEntityID: f.SourceEntityID,
204
+ sourceEntity: f.SourceEntity,
205
+ sourceFieldName: f.SourceFieldName,
206
+ isComputed: f.IsComputed,
207
+ computationDescription: f.ComputationDescription,
208
+ isSummary: f.IsSummary,
209
+ summaryDescription: f.SummaryDescription,
210
+ createdAt: f.CreatedAt,
211
+ updatedAt: f.UpdatedAt,
212
+ }
213
+ })
214
+ }
215
+ }
216
+ );
217
+ }
218
+
219
+ protected BuildSkipEntities(): SkipEntityInfo[] {
220
+ // build the entity info for skip in its format which is
221
+ // narrower in scope than our native MJ metadata
222
+ // don't pass the mj_core_schema entities
223
+ const md = new Metadata();
224
+ return md.Entities.filter(e => e.SchemaName !== mj_core_schema ).map((e) => {
225
+ const ret: SkipEntityInfo = {
226
+ id: e.ID,
227
+ name: e.Name,
228
+ schemaName: e.SchemaName,
229
+ baseView: e.BaseView,
230
+ description: e.Description,
231
+ fields: e.Fields.map((f) => {
232
+ return {
233
+ id: f.ID,
234
+ entityID: f.EntityID,
235
+ sequence: f.Sequence,
236
+ name: f.Name,
237
+ displayName: f.DisplayName,
238
+ category: f.Category,
239
+ type: f.Type,
240
+ description: f.Description,
241
+ isPrimaryKey: f.IsPrimaryKey,
242
+ allowsNull: f.AllowsNull,
243
+ isUnique: f.IsUnique,
244
+ length: f.Length,
245
+ precision: f.Precision,
246
+ scale: f.Scale,
247
+ sqlFullType: f.SQLFullType,
248
+ defaultValue: f.DefaultValue,
249
+ autoIncrement: f.AutoIncrement,
250
+ valueListType: f.ValueListType,
251
+ extendedType: f.ExtendedType,
252
+ defaultInView: f.DefaultInView,
253
+ defaultColumnWidth: f.DefaultColumnWidth,
254
+ isVirtual: f.IsVirtual,
255
+ isNameField: f.IsNameField,
256
+ relatedEntityID: f.RelatedEntityID,
257
+ relatedEntityFieldName: f.RelatedEntityFieldName,
258
+ relatedEntity: f.RelatedEntity,
259
+ relatedEntitySchemaName: f.RelatedEntitySchemaName,
260
+ relatedEntityBaseView: f.RelatedEntityBaseView,
261
+ };
262
+ }
263
+ ),
264
+ relatedEntities: e.RelatedEntities.map((r) => {
265
+ return {
266
+ entityID: r.EntityID,
267
+ relatedEntityID: r.RelatedEntityID,
268
+ type: r.Type,
269
+ entityKeyField: r.EntityKeyField,
270
+ relatedEntityJoinField: r.RelatedEntityJoinField,
271
+ joinView: r.JoinView,
272
+ joinEntityJoinField: r.JoinEntityJoinField,
273
+ joinEntityInverseJoinField: r.JoinEntityInverseJoinField,
274
+ entity: r.Entity,
275
+ entityBaseView: r.EntityBaseView,
276
+ relatedEntity: r.RelatedEntity,
277
+ relatedEntityBaseView: r.RelatedEntityBaseView,
278
+ }
279
+ })
280
+ };
281
+ return ret;
282
+ });
283
+ }
284
+
285
+ protected async HandleSkipInitialObjectLoading(dataSource: DataSource,
286
+ ConversationId: number,
287
+ UserQuestion: string,
288
+ user: UserInfo,
289
+ userPayload: UserPayload,
290
+ md: Metadata,
291
+ DataContextId: number): Promise<{convoEntity: ConversationEntity,
292
+ dataContextEntity: DataContextEntity,
293
+ convoDetailEntity: ConversationDetailEntity,
294
+ dataContext: DataContext}> {
295
+ const convoEntity = <ConversationEntity>await md.GetEntityObject('Conversations', user);
296
+ let dataContextEntity: DataContextEntity;
297
+
298
+ if (!ConversationId || ConversationId <= 0) {
299
+ // create a new conversation id
300
+ convoEntity.NewRecord();
301
+ if (user) {
302
+ convoEntity.UserID = user.ID;
303
+ convoEntity.Name = AskSkipResolver._defaultNewChatName;
304
+
305
+ dataContextEntity = await md.GetEntityObject<DataContextEntity>('Data Contexts', user);
306
+ if (!DataContextId || DataContextId <= 0) {
307
+ dataContextEntity.NewRecord();
308
+ dataContextEntity.UserID = user.ID;
309
+ dataContextEntity.Name = 'Data Context for Skip Conversation';
310
+ if (!await dataContextEntity.Save())
311
+ throw new Error(`Creating a new data context failed`);
312
+ }
313
+ else {
314
+ await dataContextEntity.Load(DataContextId);
315
+ }
316
+ convoEntity.DataContextID = dataContextEntity.ID;
317
+ if (await convoEntity.Save()) {
318
+ ConversationId = convoEntity.ID;
319
+ if (!DataContextId || dataContextEntity.ID <= 0) {
320
+ // only do this if we created a new data context for this conversation
321
+ dataContextEntity.Name += ` ${ConversationId}`;
322
+ await dataContextEntity.Save();
323
+ }
324
+ }
325
+ else
326
+ throw new Error(`Creating a new conversation failed`);
327
+ }
328
+ else {
329
+ throw new Error(`User ${userPayload.email} not found in UserCache`);
330
+ }
331
+ } else {
332
+ await convoEntity.Load(ConversationId); // load the existing conversation, will need it later
333
+ dataContextEntity = await md.GetEntityObject<DataContextEntity>('Data Contexts', user);
334
+
335
+ // note - we ignore the parameter DataContextId if it is passed in, we will use the data context from the conversation that is saved. If a user wants to change the data context for a convo, they can do that elsewhere
336
+ if (DataContextId && DataContextId > 0 && DataContextId !== convoEntity.DataContextID) {
337
+ if (convoEntity.DataContextID === null) {
338
+ convoEntity.DataContextID = DataContextId;
339
+ await convoEntity.Save();
340
+ }
341
+ else
342
+ console.warn(`AskSkipResolver: DataContextId ${DataContextId} was passed in but it was ignored because it was different than the DataContextID in the conversation ${convoEntity.DataContextID}`);
343
+ }
344
+
345
+ await dataContextEntity.Load(convoEntity.DataContextID);
346
+ }
347
+
348
+
349
+ // now, create a conversation detail record for the user message
350
+ const convoDetailEntity = await md.GetEntityObject<ConversationDetailEntity>('Conversation Details', user);
351
+ convoDetailEntity.NewRecord();
352
+ convoDetailEntity.ConversationID = ConversationId;
353
+ convoDetailEntity.Message = UserQuestion;
354
+ convoDetailEntity.Role = 'User';
355
+ convoDetailEntity.Set('Sequence', 1); // using weakly typed here because we're going to get rid of this field soon
356
+ await convoDetailEntity.Save();
357
+
358
+ const dataContext = MJGlobal.Instance.ClassFactory.CreateInstance<DataContext>(DataContext); // await this.LoadDataContext(md, dataSource, dataContextEntity, user, false);
359
+ await dataContext.Load(dataContextEntity.ID, dataSource, false, user);
360
+ return {dataContext, convoEntity, dataContextEntity, convoDetailEntity};
361
+ }
362
+
363
+ protected async LoadConversationDetailsIntoSkipMessages(dataSource: DataSource, ConversationId: number, maxHistoricalMessages?: number): Promise<SkipMessage[]> {
364
+ try {
365
+ // load up all the conversation details from the database server
366
+ const md = new Metadata();
367
+ const e = md.Entities.find((e) => e.Name === 'Conversation Details');
368
+ const sql = `SELECT
369
+ ${maxHistoricalMessages ? 'TOP ' + maxHistoricalMessages : ''} ID, Message, Role, CreatedAt
370
+ FROM
371
+ ${e.SchemaName}.${e.BaseView}
372
+ WHERE
373
+ ConversationID = ${ConversationId}
374
+ ORDER
375
+ BY CreatedAt DESC`;
376
+ const result = await dataSource.query(sql);
377
+ if (!result)
378
+ throw new Error(`Error running SQL: ${sql}`);
379
+ else {
380
+ // first, let's sort the result array into a local variable called returnData and in that we will sort by CreatedAt in ASCENDING order so we have the right chronological order
381
+ // the reason we're doing a LOCAL sort here is because in the SQL query above, we're sorting in DESCENDING order so we can use the TOP clause to limit the number of records and get the
382
+ // N most recent records. We want to sort in ASCENDING order because we want to send the messages to the Skip API in the order they were created.
383
+ const returnData = result.sort((a: any, b: any) => {
384
+ const aDate = new Date(a.CreatedAt);
385
+ const bDate = new Date(b.CreatedAt);
386
+ return aDate.getTime() - bDate.getTime();
387
+ });
388
+
389
+ // now, we will map the returnData into an array of SkipMessages
390
+ return returnData.map((r: ConversationDetailEntity) => {
391
+ // we want to limit the # of characters in the message to 5000, rough approximation for 1000 words/tokens
392
+ // but we only do that for system messages
393
+ const skipRole = this.MapDBRoleToSkipRole(r.Role);
394
+ let outputMessage; // will be populated below for system messages
395
+ if (skipRole === 'system') {
396
+ const detail = <SkipAPIResponse>JSON.parse(r.Message);
397
+ if (detail.responsePhase === SkipResponsePhase.AnalysisComplete) {
398
+ const analysisDetail = <SkipAPIAnalysisCompleteResponse>detail;
399
+ outputMessage = JSON.stringify({
400
+ responsePhase: SkipResponsePhase.AnalysisComplete,
401
+ techExplanation: analysisDetail.techExplanation,
402
+ userExplanation: analysisDetail.userExplanation,
403
+ scriptText: analysisDetail.scriptText,
404
+ tableDataColumns: analysisDetail.tableDataColumns
405
+ });
406
+ }
407
+ else if (detail.responsePhase === SkipResponsePhase.ClarifyingQuestion) {
408
+ const clarifyingQuestionDetail = <SkipAPIClarifyingQuestionResponse>detail;
409
+ outputMessage = JSON.stringify({
410
+ responsePhase: SkipResponsePhase.ClarifyingQuestion,
411
+ clarifyingQuestion: clarifyingQuestionDetail.clarifyingQuestion
412
+ });
413
+ }
414
+ else {
415
+ // we should never get here, AI responses only fit the above
416
+ // don't throw an exception, but log an error
417
+ LogError(`Unknown response phase: ${detail.responsePhase}`);
418
+ }
419
+ }
420
+ const m: SkipMessage = {
421
+ content: skipRole === 'system' ? outputMessage : r.Message,
422
+ role: skipRole,
423
+ };
424
+ return m;
425
+ });
426
+ }
427
+ }
428
+ catch (e) {
429
+ LogError(e);
430
+ throw e;
431
+ }
432
+ }
433
+
434
+ protected MapDBRoleToSkipRole(role: string): "user" | "system" {
435
+ switch (role.trim().toLowerCase()) {
436
+ case 'ai':
437
+ case 'system':
438
+ case 'assistant':
439
+ return 'system';
440
+ default:
441
+ return 'user';
442
+ }
443
+ }
444
+
445
+ protected async HandleSkipRequest(input: SkipAPIRequest, UserQuestion: string, user: UserInfo, dataSource: DataSource,
446
+ ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, md: Metadata,
447
+ convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
448
+ dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
449
+ LogStatus(` >>> Sending request to Skip API: ${___skipAPIurl}`)
450
+
451
+ const response = await sendPostRequest(___skipAPIurl, input, true, null, (message) => {
452
+ if (message.type==='status_update')
453
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
454
+ message: JSON.stringify({
455
+ type: 'AskSkip',
456
+ status: 'OK',
457
+ message: message.value.messages[0].content
458
+ }),
459
+ sessionId: userPayload.sessionId,
460
+ });
461
+ });
462
+
463
+ if (response && response.length > 0) { // response.status === 200) {
464
+ // the last object in the response array is the final response from the Skip API
465
+ const apiResponse = <SkipAPIResponse>response[response.length - 1].value;
466
+ //const apiResponse = <SkipAPIResponse>response.data;
467
+ LogStatus(` Skip API response: ${apiResponse.responsePhase}`)
468
+ this.PublishApiResponseUserUpdateMessage(apiResponse, userPayload, pubSub);
469
+
470
+ // now, based on the result type, we will either wait for the next phase or we will process the results
471
+ if (apiResponse.responsePhase === 'data_request') {
472
+ return await this.HandleDataRequestPhase(input, <SkipAPIDataRequestResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
473
+ }
474
+ else if (apiResponse.responsePhase === 'clarifying_question') {
475
+ // need to send the request back to the user for a clarifying question
476
+ return await this.HandleClarifyingQuestionPhase(input, <SkipAPIClarifyingQuestionResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity);
477
+ }
478
+ else if (apiResponse.responsePhase === 'analysis_complete') {
479
+ return await this.HandleAnalysisComplete(input, <SkipAPIAnalysisCompleteResponse>apiResponse, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
480
+ }
481
+ else {
482
+ // unknown response phase
483
+ throw new Error(`Unknown Skip API response phase: ${apiResponse.responsePhase}`);
484
+ }
485
+ }
486
+ else {
487
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
488
+ message: JSON.stringify({
489
+ type: 'AskSkip',
490
+ status: 'Error',
491
+ message: 'Analysis failed to run, please try again later and if this continues, contact your support desk.',
492
+ }),
493
+ sessionId: userPayload.sessionId,
494
+ });
495
+
496
+ return {
497
+ Success: false,
498
+ Status: 'Error',
499
+ Result: `User Question ${UserQuestion} didn't work!`,
500
+ ResponsePhase: SkipResponsePhase.AnalysisComplete,
501
+ ConversationId: ConversationId,
502
+ UserMessageConversationDetailId: 0,
503
+ AIMessageConversationDetailId: 0,
504
+ };
505
+ }
506
+ }
507
+
508
+ protected async PublishApiResponseUserUpdateMessage(apiResponse: SkipAPIResponse, userPayload: UserPayload, pubSub: PubSubEngine) {
509
+ let sUserMessage: string = '';
510
+ switch (apiResponse.responsePhase) {
511
+ case 'data_request':
512
+ sUserMessage = 'We need to gather some more data, I will do that next and update you soon.';
513
+ break;
514
+ case 'analysis_complete':
515
+ sUserMessage = 'I have completed the analysis, the results will be available momentarily.';
516
+ break;
517
+ case 'clarifying_question':
518
+ // don't send an update because the actual message will happen and show up in the UI, so this is redundant
519
+ //sUserMessage = 'I have a clarifying question for you, please see review our chat so you can provide me a little more info.';
520
+ break;
521
+ }
522
+
523
+ // update the UI
524
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
525
+ message: JSON.stringify({
526
+ type: 'AskSkip',
527
+ status: 'OK',
528
+ message: sUserMessage,
529
+ }),
530
+ sessionId: userPayload.sessionId,
531
+ });
532
+ }
533
+
534
+ protected async HandleAnalysisComplete(apiRequest: SkipAPIRequest, apiResponse: SkipAPIAnalysisCompleteResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
535
+ ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
536
+ dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
537
+ // analysis is complete
538
+ // all done, wrap things up
539
+ const md = new Metadata();
540
+ const {AIMessageConversationDetailID} = await this.FinishConversationAndNotifyUser(apiResponse, dataContext, dataContextEntity, md, user, convoEntity, pubSub, userPayload);
541
+
542
+ return {
543
+ Success: true,
544
+ Status: 'OK',
545
+ ResponsePhase: SkipResponsePhase.AnalysisComplete,
546
+ ConversationId: ConversationId,
547
+ UserMessageConversationDetailId: convoDetailEntity.ID,
548
+ AIMessageConversationDetailId: AIMessageConversationDetailID,
549
+ Result: JSON.stringify(apiResponse)
550
+ };
551
+ }
552
+
553
+ protected async HandleClarifyingQuestionPhase(apiRequest: SkipAPIRequest, apiResponse: SkipAPIClarifyingQuestionResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
554
+ ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity): Promise<AskSkipResultType> {
555
+ // need to create a message here in the COnversation and then pass that id below
556
+ const md = new Metadata();
557
+ const convoDetailEntityAI = <ConversationDetailEntity>await md.GetEntityObject('Conversation Details', user);
558
+ convoDetailEntityAI.NewRecord();
559
+ convoDetailEntityAI.ConversationID = ConversationId;
560
+ convoDetailEntityAI.Message = JSON.stringify(apiResponse);//.clarifyingQuestion;
561
+ convoDetailEntityAI.Role = 'AI';
562
+ if (await convoDetailEntityAI.Save()) {
563
+ return {
564
+ Success: true,
565
+ Status: 'OK',
566
+ ResponsePhase: SkipResponsePhase.ClarifyingQuestion,
567
+ ConversationId: ConversationId,
568
+ UserMessageConversationDetailId: convoDetailEntity.ID,
569
+ AIMessageConversationDetailId: convoDetailEntityAI.ID,
570
+ Result: JSON.stringify(apiResponse)
571
+ };
572
+ }
573
+ else {
574
+ return {
575
+ Success: false,
576
+ Status: 'Error',
577
+ ResponsePhase: SkipResponsePhase.ClarifyingQuestion,
578
+ ConversationId: ConversationId,
579
+ UserMessageConversationDetailId: convoDetailEntity.ID,
580
+ AIMessageConversationDetailId: convoDetailEntityAI.ID,
581
+ Result: JSON.stringify(apiResponse)
582
+ };
583
+ }
584
+ }
585
+
586
+ protected async HandleDataRequestPhase(apiRequest: SkipAPIRequest, apiResponse: SkipAPIDataRequestResponse, UserQuestion: string, user: UserInfo, dataSource: DataSource,
587
+ ConversationId: number, userPayload: UserPayload, pubSub: PubSubEngine, convoEntity: ConversationEntity, convoDetailEntity: ConversationDetailEntity,
588
+ dataContext: DataContext, dataContextEntity: DataContextEntity): Promise<AskSkipResultType> {
589
+ // our job in this method is to go through each of the data requests from the Skip API, get the data, and then go back to the Skip API again and to the next phase
590
+ try {
591
+ if (!apiResponse.success) {
592
+ LogError(`Data request/gathering from Skip API failed: ${apiResponse.error}`);
593
+ return {
594
+ Success: false,
595
+ Status: `The Skip API Server data gathering phase returned a non-recoverable error. Try again later and Skip might be able to handle this request.\n${apiResponse.error}`,
596
+ ResponsePhase: SkipResponsePhase.DataRequest,
597
+ ConversationId: ConversationId,
598
+ UserMessageConversationDetailId: convoDetailEntity.ID,
599
+ AIMessageConversationDetailId: 0,
600
+ Result: JSON.stringify(apiResponse)
601
+ };
602
+ }
603
+
604
+ const _maxDataGatheringRetries = 5;
605
+ const _dataGatheringFailureHeaderMessage = '***DATA GATHERING FAILURE***';
606
+ const md = new Metadata();
607
+ const executionErrors = [];
608
+ let dataRequest = apiResponse.dataRequest;
609
+
610
+ // first, in this situation we want to add a message to our apiRequest so that it is part of the message history with the server
611
+ apiRequest.messages.push({
612
+ content: `Skip API Requested Data as shown below
613
+ ${JSON.stringify(apiResponse.dataRequest)}`,
614
+ role: 'system' // user role of system because this came from Skip, we are simplifying the message for the next round if we need to send it back
615
+ });
616
+
617
+ // check to see if apiResponse.dataRequest is an array, if not, see if it is a single item, and if not, then throw an error
618
+ if (!Array.isArray(dataRequest)) {
619
+ if (dataRequest) {
620
+ dataRequest = [dataRequest];
621
+ }
622
+ else {
623
+ const errorMessage = `Data request from Skip API is not an array and not a single item.`;
624
+ LogError(errorMessage);
625
+ executionErrors.push({dataRequest: apiResponse.dataRequest, errorMessage: errorMessage});
626
+ dataRequest = []; // make a blank array so we can continue
627
+ }
628
+ }
629
+
630
+ for (const dr of dataRequest) {
631
+ try {
632
+ const item = dataContext.AddDataContextItem();
633
+ switch (dr.type) {
634
+ case "sql":
635
+ item.Type = 'sql';
636
+ item.SQL = dr.text;
637
+ item.AdditionalDescription = dr.description;
638
+ if (!await item.LoadData(dataSource, false, user))
639
+ throw new Error(`SQL data request failed: ${item.DataLoadingError}`);
640
+ break;
641
+ case "stored_query":
642
+ const queryName = dr.text;
643
+ const query = md.Queries.find((q) => q.Name === queryName);
644
+ if (query) {
645
+ item.Type = 'query';
646
+ item.QueryID = query.ID;
647
+ item.RecordName = query.Name;
648
+ item.AdditionalDescription = dr.description;
649
+ if (!await item.LoadData(dataSource, false, user))
650
+ throw new Error(`SQL data request failed: ${item.DataLoadingError}`);
651
+ }
652
+ else
653
+ throw new Error(`Query ${queryName} not found.`);
654
+ break;
655
+ default:
656
+ throw new Error(`Unknown data request type: ${dr.type}`);
657
+ break;
658
+ }
659
+ }
660
+ catch (e) {
661
+ LogError(e);
662
+ executionErrors.push({dataRequest: dr, errorMessage: e && e.message ? e.message : e.toString()});
663
+ }
664
+ }
665
+
666
+ if (executionErrors.length > 0) {
667
+ const dataGatheringFailedAttemptCount = apiRequest.messages.filter((m) => m.content.includes(_dataGatheringFailureHeaderMessage)).length + 1;
668
+ if (dataGatheringFailedAttemptCount > _maxDataGatheringRetries) {
669
+ // we have exceeded the max retries, so in this case we do NOT go back to Skip, instead we just send the errors back to the user
670
+ LogStatus(`Execution errors for Skip data request occured, and we have exceeded the max retries${_maxDataGatheringRetries}, sending errors back to the user.`);
671
+ return {
672
+ Success: false,
673
+ Status: 'Error gathering data and we have exceedded the max retries. Try again later and Skip might be able to handle this request.',
674
+ ResponsePhase: SkipResponsePhase.DataRequest,
675
+ ConversationId: ConversationId,
676
+ UserMessageConversationDetailId: convoDetailEntity.ID,
677
+ AIMessageConversationDetailId: 0,
678
+ Result: JSON.stringify(apiResponse)
679
+ };
680
+ }
681
+ else {
682
+ LogStatus(`Execution errors for Skip data request occured, sending those errors back to the Skip API to get new instructions.`);
683
+ apiRequest.requestPhase = 'data_gathering_failure';
684
+ apiRequest.messages.push({
685
+ content: `${_dataGatheringFailureHeaderMessage} #${dataGatheringFailedAttemptCount} of ${_maxDataGatheringRetries} attempts to gather data failed. Errors:
686
+ ${JSON.stringify(executionErrors)}
687
+ `,
688
+ role: 'user' // use user role becuase to the Skip API what we send it is "user"
689
+ });
690
+ }
691
+ }
692
+ else {
693
+ await dataContext.SaveItems(user, false); // save the data context items
694
+ // replace the data context copy that is in the apiRequest.
695
+ apiRequest.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
696
+ apiRequest.requestPhase = 'data_gathering_response';
697
+ }
698
+ // we have all of the data now, add it to the data context and then submit it back to the Skip API
699
+ return this.HandleSkipRequest(apiRequest, UserQuestion, user, dataSource, ConversationId, userPayload, pubSub, md, convoEntity, convoDetailEntity, dataContext, dataContextEntity);
700
+ }
701
+ catch (e) {
702
+ LogError(e);
703
+ throw e;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * This method will handle the process for an end of successful request where a user is notified of an AI message. The AI message is either the finished report or a clarifying question.
709
+ * @param apiResponse
710
+ * @param md
711
+ * @param user
712
+ * @param convoEntity
713
+ * @param pubSub
714
+ * @param userPayload
715
+ * @returns
716
+ */
717
+ protected async FinishConversationAndNotifyUser(apiResponse: SkipAPIAnalysisCompleteResponse, dataContext: DataContext, dataContextEntity: DataContextEntity, md: Metadata, user: UserInfo, convoEntity: ConversationEntity, pubSub: PubSubEngine, userPayload: UserPayload): Promise<{AIMessageConversationDetailID: number}> {
718
+ const sTitle = apiResponse.reportTitle;
719
+ const sResult = JSON.stringify(apiResponse);
720
+
721
+ // Create a conversation detail record for the Skip response
722
+ const convoDetailEntityAI = <ConversationDetailEntity>await md.GetEntityObject('Conversation Details', user);
723
+ convoDetailEntityAI.NewRecord();
724
+ convoDetailEntityAI.ConversationID = convoEntity.ID;
725
+ convoDetailEntityAI.Message = sResult;
726
+ convoDetailEntityAI.Role = 'AI';
727
+ convoDetailEntityAI.Set('Sequence', 2); // using weakly typed here because we're going to get rid of this field soon
728
+ await convoDetailEntityAI.Save();
729
+
730
+ // finally update the convo name if it is still the default
731
+ if (convoEntity.Name === AskSkipResolver._defaultNewChatName && sTitle && sTitle !== AskSkipResolver._defaultNewChatName) {
732
+ convoEntity.Name = sTitle; // use the title from the response
733
+ await convoEntity.Save();
734
+ }
735
+
736
+ // now create a notification for the user
737
+ const userNotification = <UserNotificationEntity>await md.GetEntityObject('User Notifications', user);
738
+ userNotification.NewRecord();
739
+ userNotification.UserID = user.ID;
740
+ userNotification.Title = 'Report Created: ' + sTitle;
741
+ userNotification.Message = `Good news! Skip finished creating a report for you, click on this notification to jump back into the conversation.`;
742
+ userNotification.Unread = true;
743
+ userNotification.ResourceConfiguration = JSON.stringify({
744
+ type: 'askskip',
745
+ conversationId: convoEntity.ID,
746
+ });
747
+ await userNotification.Save();
748
+
749
+ // Save the data context items...
750
+ // FOR NOW, we don't want to store the data in the database, we will just load it from the data context when we need it
751
+ // we need a better strategy to persist because the cost of storage and retrieval/parsing is higher than just running the query again in many/most cases
752
+ dataContext.SaveItems(user, false);
753
+
754
+ // send a UI update trhough pub-sub
755
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
756
+ message: JSON.stringify({
757
+ type: 'UserNotifications',
758
+ status: 'OK',
759
+ details: {
760
+ action: 'create',
761
+ recordId: userNotification.ID,
762
+ },
763
+ }),
764
+ sessionId: userPayload.sessionId,
765
+ });
766
+
767
+ return {
768
+ AIMessageConversationDetailID: convoDetailEntityAI.ID
769
+ };
770
+ }
771
+
772
+ protected async getViewData(ViewId: number, user: UserInfo): Promise<any> {
773
+ const rv = new RunView();
774
+ const result = await rv.RunView({ViewID: ViewId, IgnoreMaxRows: true}, user);
775
+ if (result && result.Success)
776
+ return result.Results;
777
+ else
778
+ throw new Error(`Error running view ${ViewId}`);
779
+ }
780
+ }
781
+
782
+ export default AskSkipResolver;