@finos/legend-lego 2.0.187 → 2.0.189

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 (44) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAITypes.d.ts +196 -0
  4. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -0
  5. package/lib/legend-ai/LegendAITypes.js +281 -0
  6. package/lib/legend-ai/LegendAITypes.js.map +1 -0
  7. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +127 -0
  8. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +63 -0
  10. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -0
  11. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +29 -0
  12. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -0
  13. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +98 -0
  14. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -0
  15. package/lib/legend-ai/components/LegendAIChat.d.ts +23 -0
  16. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -0
  17. package/lib/legend-ai/components/LegendAIChat.js +179 -0
  18. package/lib/legend-ai/components/LegendAIChat.js.map +1 -0
  19. package/lib/legend-ai/components/LegendAIErrorBoundary.d.ts +31 -0
  20. package/lib/legend-ai/components/LegendAIErrorBoundary.d.ts.map +1 -0
  21. package/lib/legend-ai/components/LegendAIErrorBoundary.js +35 -0
  22. package/lib/legend-ai/components/LegendAIErrorBoundary.js.map +1 -0
  23. package/lib/legend-ai/components/LegendAIResultGrid.d.ts +20 -0
  24. package/lib/legend-ai/components/LegendAIResultGrid.d.ts.map +1 -0
  25. package/lib/legend-ai/components/LegendAIResultGrid.js +90 -0
  26. package/lib/legend-ai/components/LegendAIResultGrid.js.map +1 -0
  27. package/lib/legend-ai/index.d.ts +22 -0
  28. package/lib/legend-ai/index.d.ts.map +1 -0
  29. package/lib/legend-ai/index.js +22 -0
  30. package/lib/legend-ai/index.js.map +1 -0
  31. package/lib/legend-ai/stores/LegendAIChatState.d.ts +46 -0
  32. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -0
  33. package/lib/legend-ai/stores/LegendAIChatState.js +559 -0
  34. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -0
  35. package/package.json +7 -3
  36. package/src/legend-ai/LegendAITypes.ts +386 -0
  37. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +208 -0
  38. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +139 -0
  39. package/src/legend-ai/components/LegendAIChat.tsx +502 -0
  40. package/src/legend-ai/components/LegendAIErrorBoundary.tsx +42 -0
  41. package/src/legend-ai/components/LegendAIResultGrid.tsx +132 -0
  42. package/src/legend-ai/index.ts +46 -0
  43. package/src/legend-ai/stores/LegendAIChatState.ts +1004 -0
  44. package/tsconfig.json +8 -0
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * Copyright (c) 2026-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { useState, useRef, useCallback, useEffect } from 'react';
18
+ import { assertErrorThrown, noop, uuid } from '@finos/legend-shared';
19
+ import {
20
+ type TDSServiceSchema,
21
+ type LegendAIConfig,
22
+ type LegendAIChatState,
23
+ type LegendAIAssistantMessage,
24
+ type LegendAIUserMessage,
25
+ type LegendAIMessage,
26
+ type LegendAIConversationTurn,
27
+ type LegendAIProductMetadata,
28
+ LegendAIQuestionIntent,
29
+ LegendAIThinkingStepStatus,
30
+ LegendAIMessageRole,
31
+ TDSServiceSourceType,
32
+ buildColumnDefsFromNames,
33
+ } from '../LegendAITypes.js';
34
+ import {
35
+ type LegendAI_LegendApplicationPlugin_Extension,
36
+ type LegendAIOrchestratorDataProductCoordinates,
37
+ type LegendAISqlExecutionResultData,
38
+ LegendAIJudgeVerdict,
39
+ } from '../LegendAI_LegendApplicationPlugin_Extension.js';
40
+ import type { QueryExplicitExecutionContextInfo } from '@finos/legend-graph';
41
+
42
+ const MAX_ERROR_MESSAGE_LENGTH = 500;
43
+ const MAX_THINKING_ERROR_PREVIEW_LENGTH = 200;
44
+ const DEFAULT_MAX_JUDGE_ATTEMPTS = 5;
45
+
46
+ const SUGGESTED_QUERIES_DELIMITER = '---SUGGESTED_QUERIES---';
47
+
48
+ function deduplicateColumns(columns: string[]): string[] {
49
+ const seen = new Map<string, number>();
50
+ return columns.map((col) => {
51
+ const count = seen.get(col) ?? 0;
52
+ seen.set(col, count + 1);
53
+ return count === 0 ? col : `${col}_${count + 1}`;
54
+ });
55
+ }
56
+
57
+ export type MessageSetter = React.Dispatch<
58
+ React.SetStateAction<LegendAIMessage[]>
59
+ >;
60
+
61
+ function createMessagePair(
62
+ text: string,
63
+ ): [LegendAIUserMessage, LegendAIAssistantMessage] {
64
+ return [
65
+ { id: uuid(), role: LegendAIMessageRole.USER, text },
66
+ {
67
+ id: uuid(),
68
+ role: LegendAIMessageRole.ASSISTANT,
69
+ thinkingSteps: [],
70
+ sql: null,
71
+ textAnswer: null,
72
+ gridData: null,
73
+ error: null,
74
+ sqlGenTime: null,
75
+ execTime: null,
76
+ thinkingDuration: null,
77
+ isProcessing: true,
78
+ isExecuting: false,
79
+ suggestedQueries: [],
80
+ },
81
+ ];
82
+ }
83
+
84
+ interface LegendAIOperationContext {
85
+ config: LegendAIConfig;
86
+ plugin: LegendAI_LegendApplicationPlugin_Extension;
87
+ history: LegendAIConversationTurn[];
88
+ setMessages: MessageSetter;
89
+ }
90
+
91
+ interface LegendAIOrchestratorOptionsParam {
92
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates;
93
+ pureExecutionContext?: QueryExplicitExecutionContextInfo;
94
+ }
95
+
96
+ export function updateLastAssistant(
97
+ setMessages: MessageSetter,
98
+ updater: (msg: LegendAIAssistantMessage) => Partial<LegendAIAssistantMessage>,
99
+ ): void {
100
+ setMessages((prev) => {
101
+ const newMsgs = [...prev];
102
+ const lastIdx = newMsgs.length - 1;
103
+ const last = newMsgs[lastIdx];
104
+ if (last?.role === LegendAIMessageRole.ASSISTANT) {
105
+ newMsgs[lastIdx] = { ...last, ...updater(last) };
106
+ }
107
+ return newMsgs;
108
+ });
109
+ }
110
+
111
+ export function addThinkingStep(
112
+ setMessages: MessageSetter,
113
+ label: string,
114
+ ): void {
115
+ updateLastAssistant(setMessages, (msg) => ({
116
+ thinkingSteps: [
117
+ ...msg.thinkingSteps.map((s) =>
118
+ s.status === LegendAIThinkingStepStatus.ACTIVE
119
+ ? { ...s, status: LegendAIThinkingStepStatus.DONE }
120
+ : s,
121
+ ),
122
+ { label, status: LegendAIThinkingStepStatus.ACTIVE },
123
+ ],
124
+ }));
125
+ }
126
+
127
+ export function completeThinkingSteps(setMessages: MessageSetter): void {
128
+ updateLastAssistant(setMessages, (msg) => ({
129
+ thinkingSteps: msg.thinkingSteps.map((s) =>
130
+ s.status === LegendAIThinkingStepStatus.ACTIVE
131
+ ? { ...s, status: LegendAIThinkingStepStatus.DONE }
132
+ : s,
133
+ ),
134
+ }));
135
+ }
136
+
137
+ export function finishWithThinkingError(
138
+ setMessages: MessageSetter,
139
+ errorMsg: string,
140
+ startTime: number,
141
+ ): void {
142
+ updateLastAssistant(setMessages, (msg) => ({
143
+ thinkingSteps: msg.thinkingSteps.map((s) =>
144
+ s.status === LegendAIThinkingStepStatus.ACTIVE
145
+ ? { ...s, status: LegendAIThinkingStepStatus.ERROR }
146
+ : s,
147
+ ),
148
+ error: errorMsg.slice(0, MAX_ERROR_MESSAGE_LENGTH),
149
+ isProcessing: false,
150
+ thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
151
+ }));
152
+ }
153
+
154
+ export function buildConversationHistory(
155
+ messages: LegendAIMessage[],
156
+ ): LegendAIConversationTurn[] {
157
+ const history: LegendAIConversationTurn[] = [];
158
+ let i = 0;
159
+ while (i < messages.length - 1) {
160
+ const userMsg = messages[i];
161
+ const asstMsg = messages[i + 1];
162
+ if (
163
+ userMsg?.role === LegendAIMessageRole.USER &&
164
+ asstMsg?.role === LegendAIMessageRole.ASSISTANT
165
+ ) {
166
+ if (asstMsg.sql) {
167
+ history.push({
168
+ question: userMsg.text,
169
+ sql: asstMsg.sql,
170
+ intent: LegendAIQuestionIntent.DATA_QUERY,
171
+ });
172
+ } else if (asstMsg.textAnswer) {
173
+ history.push({
174
+ question: userMsg.text,
175
+ sql: asstMsg.textAnswer,
176
+ intent: LegendAIQuestionIntent.METADATA,
177
+ });
178
+ }
179
+ i += 2;
180
+ } else {
181
+ i += 1;
182
+ }
183
+ }
184
+ return history;
185
+ }
186
+
187
+ function formatServiceParams(services: TDSServiceSchema[]): string[] {
188
+ return services.flatMap((s) =>
189
+ s.parameters.length > 0 ? [`${s.title}: ${s.parameters.join(', ')}`] : [],
190
+ );
191
+ }
192
+
193
+ export function buildGenerationFailureMessage(
194
+ failure: string,
195
+ suggestion: string | undefined,
196
+ services: TDSServiceSchema[],
197
+ ): string {
198
+ const parts = [failure];
199
+ if (suggestion) {
200
+ parts.push(`\nTry instead: "${suggestion}"`);
201
+ }
202
+ const svcNames = services.map((s) => s.title);
203
+ if (svcNames.length > 0) {
204
+ parts.push(`\nAvailable services: ${svcNames.join(', ')}`);
205
+ }
206
+ const allParams = formatServiceParams(services);
207
+ if (allParams.length > 0) {
208
+ parts.push(`\nService parameters: ${allParams.join('; ')}`);
209
+ }
210
+ return parts.join('');
211
+ }
212
+
213
+ export function buildExecutionErrorMessage(
214
+ errStr: string,
215
+ services: TDSServiceSchema[],
216
+ ): string {
217
+ const errParts: string[] = [];
218
+ const errLower = errStr.toLowerCase();
219
+
220
+ const missingParamMatch =
221
+ /missing required parameter values?\s*\[(?<params>[^\]]+)\]/i.exec(errStr);
222
+ if (missingParamMatch) {
223
+ const paramNames = missingParamMatch.groups?.params ?? '';
224
+ const paramList = paramNames.split(',').map((p) => p.trim());
225
+ const hint = paramList
226
+ .map(
227
+ (p) =>
228
+ `a specific ${p.replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/g, '$<lower> $<upper>').toLowerCase()}`,
229
+ )
230
+ .join(' and ');
231
+ errParts.push(
232
+ `This service requires a value for: ${paramNames}`,
233
+ `\nTry rephrasing your question to include ${hint}.`,
234
+ );
235
+ const svcParams = formatServiceParams(services);
236
+ if (svcParams.length > 0) {
237
+ errParts.push(`\nService parameters:\n${svcParams.join('\n')}`);
238
+ }
239
+ return errParts.join('');
240
+ }
241
+
242
+ errParts.push(errStr.slice(0, MAX_ERROR_MESSAGE_LENGTH));
243
+ if (
244
+ errLower.includes('column') &&
245
+ (errLower.includes('not found') ||
246
+ errLower.includes('does not exist') ||
247
+ errLower.includes('unknown'))
248
+ ) {
249
+ const svcCols = services.map(
250
+ (s) => `${s.title}: ${s.columns.map((c) => c.name).join(', ')}`,
251
+ );
252
+ errParts.push(`\nAvailable columns:\n${svcCols.join('\n')}`);
253
+ }
254
+ if (errLower.includes('parameter') || errLower.includes('argument')) {
255
+ const svcParams = formatServiceParams(services);
256
+ if (svcParams.length > 0) {
257
+ errParts.push(`\nRequired parameters:\n${svcParams.join('\n')}`);
258
+ }
259
+ }
260
+ return errParts.join('');
261
+ }
262
+
263
+ function parseSuggestedQueries(rawAnswer: string): {
264
+ answer: string;
265
+ suggestedQueries: string[];
266
+ } {
267
+ const delimIndex = rawAnswer.indexOf(SUGGESTED_QUERIES_DELIMITER);
268
+ if (delimIndex === -1) {
269
+ return { answer: rawAnswer.trim(), suggestedQueries: [] };
270
+ }
271
+ const answer = rawAnswer.slice(0, delimIndex).trim();
272
+ const suggestionsBlock = rawAnswer.slice(
273
+ delimIndex + SUGGESTED_QUERIES_DELIMITER.length,
274
+ );
275
+ const suggestedQueries = suggestionsBlock
276
+ .split('\n')
277
+ .map((line) => line.replace(/^\d+[.)]\s*/, '').trim())
278
+ .filter((line) => line.length > 0)
279
+ .slice(0, 3);
280
+ return { answer, suggestedQueries };
281
+ }
282
+
283
+ export async function handleMetadataQuestion(
284
+ question: string,
285
+ metadata: LegendAIProductMetadata,
286
+ context: LegendAIOperationContext,
287
+ startTime: number,
288
+ hasQueryableServices?: boolean,
289
+ ): Promise<void> {
290
+ const { config, plugin, history, setMessages } = context;
291
+ addThinkingStep(setMessages, 'Answering from product metadata...');
292
+ const metadataPromptText = plugin.buildMetadataPrompt(
293
+ question,
294
+ metadata,
295
+ history,
296
+ );
297
+ const rawAnswer = await plugin.callLLM(metadataPromptText, config);
298
+ const { answer, suggestedQueries: parsedSuggestions } =
299
+ parseSuggestedQueries(rawAnswer);
300
+ const suggestedQueries =
301
+ hasQueryableServices === false && !config.orchestratorUrl
302
+ ? []
303
+ : parsedSuggestions;
304
+ completeThinkingSteps(setMessages);
305
+ updateLastAssistant(setMessages, () => ({
306
+ textAnswer: answer,
307
+ suggestedQueries,
308
+ isProcessing: false,
309
+ thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
310
+ }));
311
+ }
312
+
313
+ export async function generateAndJudgeSql(
314
+ question: string,
315
+ services: TDSServiceSchema[],
316
+ coordinates: string,
317
+ context: LegendAIOperationContext,
318
+ startTime: number,
319
+ ): Promise<string | null> {
320
+ const { config, plugin, history, setMessages } = context;
321
+ addThinkingStep(setMessages, 'Building context from service schemas...');
322
+ const prompt = plugin.buildGeneratorPrompt(
323
+ question,
324
+ services,
325
+ coordinates,
326
+ history,
327
+ );
328
+ addThinkingStep(setMessages, 'Generating SQL query...');
329
+
330
+ const answerText = await plugin.callLLM(prompt, config);
331
+ const {
332
+ sql: generatedSql,
333
+ failure,
334
+ suggestion,
335
+ } = plugin.extractSqlFromResponse(answerText);
336
+
337
+ if (failure) {
338
+ addThinkingStep(setMessages, `Generation failed: ${failure}`);
339
+ finishWithThinkingError(
340
+ setMessages,
341
+ buildGenerationFailureMessage(failure, suggestion, services),
342
+ startTime,
343
+ );
344
+ return null;
345
+ }
346
+
347
+ if (!generatedSql) {
348
+ addThinkingStep(setMessages, 'Could not extract SQL from response');
349
+ finishWithThinkingError(
350
+ setMessages,
351
+ 'Could not extract SQL from LLM response.\nTry rephrasing your question or ask about a specific service.',
352
+ startTime,
353
+ );
354
+ return null;
355
+ }
356
+
357
+ const maxAttempts = config.maxJudgeAttempts ?? DEFAULT_MAX_JUDGE_ATTEMPTS;
358
+ let currentSql = generatedSql;
359
+
360
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
361
+ addThinkingStep(
362
+ setMessages,
363
+ `Verifying query correctness (${attempt}/${maxAttempts})...`,
364
+ );
365
+
366
+ const judgePrompt = plugin.buildJudgePrompt(
367
+ currentSql,
368
+ question,
369
+ services,
370
+ coordinates,
371
+ history,
372
+ );
373
+ const judgeAnswer = await plugin.callLLM(judgePrompt, config);
374
+ const judgeResult = plugin.extractJudgeResult(judgeAnswer);
375
+
376
+ if (judgeResult.verdict === LegendAIJudgeVerdict.PASS) {
377
+ completeThinkingSteps(setMessages);
378
+ return currentSql;
379
+ }
380
+
381
+ const previousSql = currentSql;
382
+ const correctedSql = judgeResult.correctedSql?.trim();
383
+ if (
384
+ correctedSql !== undefined &&
385
+ (correctedSql.toLowerCase().startsWith('select') ||
386
+ correctedSql.toLowerCase().startsWith('with') ||
387
+ correctedSql.toLowerCase().startsWith('('))
388
+ ) {
389
+ addThinkingStep(setMessages, `Query corrected (attempt ${attempt})`);
390
+ currentSql = correctedSql;
391
+ }
392
+
393
+ if (currentSql === previousSql || attempt === maxAttempts) {
394
+ addThinkingStep(
395
+ setMessages,
396
+ 'Max verification attempts reached, using best query',
397
+ );
398
+ return currentSql;
399
+ }
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ function reportExecutionResult(
406
+ rawResult: LegendAISqlExecutionResultData,
407
+ setMessages: MessageSetter,
408
+ execStartTime: number,
409
+ startTime: number,
410
+ ): LegendAISqlExecutionResultData {
411
+ const columns = deduplicateColumns(rawResult.columns);
412
+ const rows = rawResult.rows;
413
+ completeThinkingSteps(setMessages);
414
+ addThinkingStep(
415
+ setMessages,
416
+ `Retrieved ${rows.length} row${rows.length === 1 ? '' : 's'}`,
417
+ );
418
+ completeThinkingSteps(setMessages);
419
+
420
+ updateLastAssistant(setMessages, () => ({
421
+ gridData: { columnDefs: buildColumnDefsFromNames(columns), rowData: rows },
422
+ execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
423
+ isProcessing: false,
424
+ isExecuting: false,
425
+ thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
426
+ }));
427
+ return { columns, rows };
428
+ }
429
+
430
+ export async function executeSqlAndReport(
431
+ sql: string,
432
+ services: TDSServiceSchema[],
433
+ config: LegendAIConfig,
434
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
435
+ setMessages: MessageSetter,
436
+ startTime: number,
437
+ dataProductCoordinates?: LegendAIOrchestratorDataProductCoordinates,
438
+ ): Promise<LegendAISqlExecutionResultData | undefined> {
439
+ const execStartTime = Date.now();
440
+ try {
441
+ const isAccessPoint = services.some(
442
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
443
+ );
444
+ const rawResult =
445
+ isAccessPoint && dataProductCoordinates
446
+ ? await plugin.executeLakehouseSql(sql, dataProductCoordinates, config)
447
+ : await plugin.executeSql(sql, config);
448
+ return reportExecutionResult(
449
+ rawResult,
450
+ setMessages,
451
+ execStartTime,
452
+ startTime,
453
+ );
454
+ } catch (executeError) {
455
+ assertErrorThrown(executeError);
456
+ addThinkingStep(
457
+ setMessages,
458
+ `Execution failed: ${executeError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
459
+ );
460
+ finishWithThinkingError(
461
+ setMessages,
462
+ buildExecutionErrorMessage(executeError.message, services),
463
+ startTime,
464
+ );
465
+ updateLastAssistant(setMessages, () => ({
466
+ execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
467
+ isExecuting: false,
468
+ }));
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ export async function executePureQueryAndReport(
474
+ pureQuery: string,
475
+ pureExecutionContext: QueryExplicitExecutionContextInfo,
476
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
477
+ config: LegendAIConfig,
478
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
479
+ setMessages: MessageSetter,
480
+ startTime: number,
481
+ ): Promise<LegendAISqlExecutionResultData> {
482
+ const execStartTime = Date.now();
483
+ try {
484
+ addThinkingStep(setMessages, 'Executing Pure query...');
485
+ const rawResult = await plugin.executePureQuery(
486
+ pureQuery,
487
+ pureExecutionContext,
488
+ dataProductCoordinates,
489
+ config,
490
+ );
491
+ return reportExecutionResult(
492
+ rawResult,
493
+ setMessages,
494
+ execStartTime,
495
+ startTime,
496
+ );
497
+ } catch (executeError) {
498
+ assertErrorThrown(executeError);
499
+ addThinkingStep(
500
+ setMessages,
501
+ `Execution failed: ${executeError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
502
+ );
503
+ completeThinkingSteps(setMessages);
504
+ updateLastAssistant(setMessages, () => ({
505
+ execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
506
+ isExecuting: false,
507
+ isProcessing: false,
508
+ error: `Execution failed: ${executeError.message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}`,
509
+ thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
510
+ }));
511
+ return { columns: [], rows: [] };
512
+ }
513
+ }
514
+
515
+ export async function processQuestionViaOrchestrator(
516
+ question: string,
517
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
518
+ _metadata: LegendAIProductMetadata,
519
+ context: LegendAIOperationContext,
520
+ pureExecutionContext?: QueryExplicitExecutionContextInfo,
521
+ ): Promise<void> {
522
+ const { config, plugin, setMessages } = context;
523
+ const startTime = Date.now();
524
+
525
+ try {
526
+ addThinkingStep(setMessages, 'Resolving entities for your query...');
527
+ const resolvedEntities = await plugin.resolveEntitiesForQuery(
528
+ question,
529
+ dataProductCoordinates,
530
+ config,
531
+ );
532
+
533
+ addThinkingStep(
534
+ setMessages,
535
+ `Found root entity: ${resolvedEntities.rootEntity.split('::').pop() ?? resolvedEntities.rootEntity}`,
536
+ );
537
+ if (resolvedEntities.relatedEntities.length > 0) {
538
+ addThinkingStep(
539
+ setMessages,
540
+ `Found ${resolvedEntities.relatedEntities.length} related entities`,
541
+ );
542
+ }
543
+
544
+ addThinkingStep(setMessages, 'Generating Legend query via orchestrator...');
545
+ const orchestratorResponse = await plugin.generateQueryViaOrchestrator(
546
+ {
547
+ user_question: question,
548
+ semantic_search_resolution_details: {
549
+ data_product_coordinates: dataProductCoordinates,
550
+ root_entity: resolvedEntities.rootEntity,
551
+ related_entities: resolvedEntities.relatedEntities,
552
+ },
553
+ },
554
+ config,
555
+ );
556
+
557
+ const queryGenTime = ((Date.now() - startTime) / 1000).toFixed(2);
558
+ completeThinkingSteps(setMessages);
559
+ updateLastAssistant(setMessages, () => ({
560
+ sql: orchestratorResponse.legend_query,
561
+ sqlGenTime: queryGenTime,
562
+ isExecuting: true,
563
+ isProcessing: true,
564
+ }));
565
+
566
+ if (!pureExecutionContext) {
567
+ updateLastAssistant(setMessages, () => ({
568
+ isProcessing: false,
569
+ isExecuting: false,
570
+ error:
571
+ 'No execution context available — cannot execute query via engine.',
572
+ thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
573
+ }));
574
+ return;
575
+ }
576
+
577
+ await executePureQueryAndReport(
578
+ orchestratorResponse.legend_query,
579
+ pureExecutionContext,
580
+ dataProductCoordinates,
581
+ config,
582
+ plugin,
583
+ setMessages,
584
+ startTime,
585
+ );
586
+ } catch (error) {
587
+ assertErrorThrown(error);
588
+ addThinkingStep(
589
+ setMessages,
590
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
591
+ );
592
+ finishWithThinkingError(setMessages, error.message, startTime);
593
+ }
594
+ }
595
+
596
+ async function processDataQuery(
597
+ question: string,
598
+ services: TDSServiceSchema[],
599
+ coordinates: string,
600
+ metadata: LegendAIProductMetadata,
601
+ context: LegendAIOperationContext,
602
+ startTime: number,
603
+ orchestratorOptions?: LegendAIOrchestratorOptionsParam,
604
+ ): Promise<void> {
605
+ const { config, plugin, setMessages } = context;
606
+ const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
607
+ const pureExecutionContext = orchestratorOptions?.pureExecutionContext;
608
+
609
+ if (services.length === 0) {
610
+ if (config.orchestratorUrl && dataProductCoordinates) {
611
+ completeThinkingSteps(setMessages);
612
+ await processQuestionViaOrchestrator(
613
+ question,
614
+ dataProductCoordinates,
615
+ metadata,
616
+ context,
617
+ pureExecutionContext,
618
+ );
619
+ return;
620
+ }
621
+ finishWithThinkingError(
622
+ setMessages,
623
+ 'No TDS services available for querying',
624
+ startTime,
625
+ );
626
+ return;
627
+ }
628
+
629
+ addThinkingStep(setMessages, 'Found relevant services to query');
630
+
631
+ const judgedSql = await generateAndJudgeSql(
632
+ question,
633
+ services,
634
+ coordinates,
635
+ context,
636
+ startTime,
637
+ );
638
+
639
+ if (!judgedSql) {
640
+ if (config.orchestratorUrl && dataProductCoordinates) {
641
+ addThinkingStep(
642
+ setMessages,
643
+ 'SQL generation could not handle this query, trying Legend AI orchestrator...',
644
+ );
645
+ updateLastAssistant(setMessages, () => ({
646
+ error: null,
647
+ isProcessing: true,
648
+ }));
649
+ await processQuestionViaOrchestrator(
650
+ question,
651
+ dataProductCoordinates,
652
+ metadata,
653
+ context,
654
+ pureExecutionContext,
655
+ );
656
+ return;
657
+ }
658
+ return;
659
+ }
660
+
661
+ const sqlGenTimeValue = ((Date.now() - startTime) / 1000).toFixed(2);
662
+ completeThinkingSteps(setMessages);
663
+ updateLastAssistant(setMessages, () => ({
664
+ sql: judgedSql,
665
+ sqlGenTime: sqlGenTimeValue,
666
+ isExecuting: true,
667
+ }));
668
+
669
+ const sqlResult = await executeSqlAndReport(
670
+ judgedSql,
671
+ services,
672
+ config,
673
+ plugin,
674
+ setMessages,
675
+ startTime,
676
+ dataProductCoordinates,
677
+ );
678
+
679
+ if (
680
+ sqlResult?.rows.length === 0 &&
681
+ config.orchestratorUrl &&
682
+ dataProductCoordinates
683
+ ) {
684
+ addThinkingStep(
685
+ setMessages,
686
+ 'SQL query returned no results, trying Legend AI orchestrator...',
687
+ );
688
+ updateLastAssistant(setMessages, () => ({
689
+ gridData: null,
690
+ error: null,
691
+ isProcessing: true,
692
+ isExecuting: false,
693
+ }));
694
+ await processQuestionViaOrchestrator(
695
+ question,
696
+ dataProductCoordinates,
697
+ metadata,
698
+ context,
699
+ pureExecutionContext,
700
+ );
701
+ }
702
+ }
703
+
704
+ export async function processQuestion(
705
+ question: string,
706
+ services: TDSServiceSchema[],
707
+ coordinates: string,
708
+ metadata: LegendAIProductMetadata,
709
+ context: LegendAIOperationContext,
710
+ dataProductCoordinates?: LegendAIOrchestratorDataProductCoordinates,
711
+ pureExecutionContext?: QueryExplicitExecutionContextInfo,
712
+ ): Promise<void> {
713
+ const { config, plugin, setMessages } = context;
714
+ const startTime = Date.now();
715
+
716
+ try {
717
+ addThinkingStep(setMessages, 'Analyzing your question...');
718
+
719
+ const serviceNames = services.map((s) => s.title);
720
+ const intent = await plugin.classifyQuestionIntent(
721
+ question,
722
+ services.length > 0,
723
+ config,
724
+ serviceNames,
725
+ );
726
+
727
+ if (intent === LegendAIQuestionIntent.METADATA) {
728
+ await handleMetadataQuestion(
729
+ question,
730
+ metadata,
731
+ context,
732
+ startTime,
733
+ services.length > 0,
734
+ );
735
+ return;
736
+ }
737
+
738
+ if (intent === LegendAIQuestionIntent.ORCHESTRATOR) {
739
+ if (config.orchestratorUrl && dataProductCoordinates) {
740
+ completeThinkingSteps(setMessages);
741
+ await processQuestionViaOrchestrator(
742
+ question,
743
+ dataProductCoordinates,
744
+ metadata,
745
+ context,
746
+ pureExecutionContext,
747
+ );
748
+ return;
749
+ }
750
+ addThinkingStep(
751
+ setMessages,
752
+ 'Orchestrator not available, trying SQL generation...',
753
+ );
754
+ }
755
+
756
+ await processDataQuery(
757
+ question,
758
+ services,
759
+ coordinates,
760
+ metadata,
761
+ context,
762
+ startTime,
763
+ dataProductCoordinates
764
+ ? {
765
+ dataProductCoordinates,
766
+ ...(pureExecutionContext === undefined
767
+ ? {}
768
+ : { pureExecutionContext }),
769
+ }
770
+ : undefined,
771
+ );
772
+ } catch (error) {
773
+ assertErrorThrown(error);
774
+ addThinkingStep(
775
+ setMessages,
776
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
777
+ );
778
+ finishWithThinkingError(setMessages, error.message, startTime);
779
+ }
780
+ }
781
+
782
+ export async function processQuestionWithIntent(
783
+ question: string,
784
+ intent: LegendAIQuestionIntent,
785
+ services: TDSServiceSchema[],
786
+ coordinates: string,
787
+ metadata: LegendAIProductMetadata,
788
+ context: LegendAIOperationContext,
789
+ orchestratorOptions?: LegendAIOrchestratorOptionsParam,
790
+ ): Promise<void> {
791
+ const { config, setMessages } = context;
792
+ const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
793
+ const pureExecutionContext = orchestratorOptions?.pureExecutionContext;
794
+
795
+ if (intent === LegendAIQuestionIntent.METADATA) {
796
+ const startTime = Date.now();
797
+ await handleMetadataQuestion(
798
+ question,
799
+ metadata,
800
+ context,
801
+ startTime,
802
+ services.length > 0,
803
+ );
804
+ return;
805
+ }
806
+
807
+ if (intent === LegendAIQuestionIntent.ORCHESTRATOR) {
808
+ if (config.orchestratorUrl && dataProductCoordinates) {
809
+ await processQuestionViaOrchestrator(
810
+ question,
811
+ dataProductCoordinates,
812
+ metadata,
813
+ context,
814
+ pureExecutionContext,
815
+ );
816
+ return;
817
+ }
818
+ }
819
+
820
+ const startTime = Date.now();
821
+
822
+ try {
823
+ addThinkingStep(setMessages, 'Preparing data query...');
824
+ await processDataQuery(
825
+ question,
826
+ services,
827
+ coordinates,
828
+ metadata,
829
+ context,
830
+ startTime,
831
+ orchestratorOptions,
832
+ );
833
+ } catch (error) {
834
+ assertErrorThrown(error);
835
+ addThinkingStep(
836
+ setMessages,
837
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
838
+ );
839
+ finishWithThinkingError(setMessages, error.message, startTime);
840
+ }
841
+ }
842
+
843
+ export const useLegendAIChatState = (
844
+ services: TDSServiceSchema[],
845
+ coordinates: string,
846
+ config: LegendAIConfig,
847
+ metadata: LegendAIProductMetadata,
848
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
849
+ dataProductCoordinates?: LegendAIOrchestratorDataProductCoordinates,
850
+ pureExecutionContext?: QueryExplicitExecutionContextInfo,
851
+ ): LegendAIChatState => {
852
+ const [questionText, setQuestionText] = useState('');
853
+ const [isSending, setIsSending] = useState(false);
854
+ const [messages, setMessages] = useState<LegendAIMessage[]>([]);
855
+ const [expandedThinking, setExpandedThinking] = useState<Set<number>>(
856
+ new Set(),
857
+ );
858
+
859
+ const conversationRef = useRef<HTMLDivElement>(null);
860
+ const sendTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
861
+ undefined,
862
+ );
863
+
864
+ useEffect(() => {
865
+ const el = conversationRef.current;
866
+ if (el && messages.length > 0) {
867
+ el.scrollTop = el.scrollHeight;
868
+ }
869
+ }, [messages]);
870
+
871
+ useEffect(() => {
872
+ return () => {
873
+ if (sendTimeoutRef.current !== undefined) {
874
+ clearTimeout(sendTimeoutRef.current);
875
+ }
876
+ };
877
+ }, []);
878
+
879
+ const toggleThinking = useCallback((index: number) => {
880
+ setExpandedThinking((prev) => {
881
+ const next = new Set(prev);
882
+ if (next.has(index)) {
883
+ next.delete(index);
884
+ } else {
885
+ next.add(index);
886
+ }
887
+ return next;
888
+ });
889
+ }, []);
890
+
891
+ const clearChat = useCallback(() => {
892
+ setMessages([]);
893
+ setExpandedThinking(new Set());
894
+ setQuestionText('');
895
+ setIsSending(false);
896
+ if (sendTimeoutRef.current !== undefined) {
897
+ clearTimeout(sendTimeoutRef.current);
898
+ sendTimeoutRef.current = undefined;
899
+ }
900
+ }, []);
901
+
902
+ const dispatchQuestion = useCallback(
903
+ (
904
+ text: string,
905
+ process: (
906
+ trimmed: string,
907
+ history: LegendAIConversationTurn[],
908
+ ) => Promise<void>,
909
+ ): void => {
910
+ const trimmed = text.trim();
911
+ if (!trimmed || isSending) {
912
+ return;
913
+ }
914
+ const history = buildConversationHistory(messages);
915
+ setIsSending(true);
916
+ setQuestionText('');
917
+ setMessages((prev) => [...prev, ...createMessagePair(trimmed)]);
918
+ if (sendTimeoutRef.current !== undefined) {
919
+ clearTimeout(sendTimeoutRef.current);
920
+ sendTimeoutRef.current = undefined;
921
+ }
922
+ sendTimeoutRef.current = setTimeout(() => {
923
+ process(trimmed, history)
924
+ .catch(noop())
925
+ .finally(() => {
926
+ setIsSending(false);
927
+ sendTimeoutRef.current = undefined;
928
+ });
929
+ }, 0);
930
+ },
931
+ [isSending, messages],
932
+ );
933
+
934
+ const askQuestion = useCallback(
935
+ (): void =>
936
+ dispatchQuestion(questionText, (trimmed, history) =>
937
+ processQuestion(
938
+ trimmed,
939
+ services,
940
+ coordinates,
941
+ metadata,
942
+ { config, plugin, history, setMessages },
943
+ dataProductCoordinates,
944
+ pureExecutionContext,
945
+ ),
946
+ ),
947
+ [
948
+ questionText,
949
+ dispatchQuestion,
950
+ services,
951
+ coordinates,
952
+ config,
953
+ metadata,
954
+ plugin,
955
+ dataProductCoordinates,
956
+ pureExecutionContext,
957
+ ],
958
+ );
959
+
960
+ const askQuestionWithIntent = useCallback(
961
+ (text: string, intent: LegendAIQuestionIntent): void =>
962
+ dispatchQuestion(text, (trimmed, history) =>
963
+ processQuestionWithIntent(
964
+ trimmed,
965
+ intent,
966
+ services,
967
+ coordinates,
968
+ metadata,
969
+ { config, plugin, history, setMessages },
970
+ dataProductCoordinates
971
+ ? {
972
+ dataProductCoordinates,
973
+ ...(pureExecutionContext === undefined
974
+ ? {}
975
+ : { pureExecutionContext }),
976
+ }
977
+ : undefined,
978
+ ),
979
+ ),
980
+ [
981
+ dispatchQuestion,
982
+ services,
983
+ coordinates,
984
+ config,
985
+ metadata,
986
+ plugin,
987
+ dataProductCoordinates,
988
+ pureExecutionContext,
989
+ ],
990
+ );
991
+
992
+ return {
993
+ questionText,
994
+ setQuestionText,
995
+ isSending,
996
+ messages,
997
+ askQuestion,
998
+ askQuestionWithIntent,
999
+ clearChat,
1000
+ expandedThinking,
1001
+ toggleThinking,
1002
+ conversationRef,
1003
+ };
1004
+ };