@finos/legend-lego 2.0.195 → 2.0.197

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/code-editor/CodeEditor.d.ts.map +1 -1
  2. package/lib/code-editor/CodeEditor.js +14 -1
  3. package/lib/code-editor/CodeEditor.js.map +1 -1
  4. package/lib/index.css +2 -2
  5. package/lib/index.css.map +1 -1
  6. package/lib/legend-ai/LegendAITypes.d.ts +33 -0
  7. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  8. package/lib/legend-ai/LegendAITypes.js +39 -1
  9. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  10. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +96 -1
  11. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  12. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +56 -0
  13. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  14. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  15. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +6 -0
  16. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  17. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts +24 -0
  18. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -0
  19. package/lib/legend-ai/components/LegendAIAnalysisPanel.js +35 -0
  20. package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -0
  21. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +23 -0
  22. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -0
  23. package/lib/legend-ai/components/LegendAIAnalysisUtils.js +168 -0
  24. package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -0
  25. package/lib/legend-ai/components/LegendAICharts.d.ts +25 -0
  26. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -0
  27. package/lib/legend-ai/components/LegendAICharts.js +70 -0
  28. package/lib/legend-ai/components/LegendAICharts.js.map +1 -0
  29. package/lib/legend-ai/components/LegendAIChat.d.ts +2 -1
  30. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  31. package/lib/legend-ai/components/LegendAIChat.js +14 -10
  32. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  33. package/lib/legend-ai/index.d.ts +5 -2
  34. package/lib/legend-ai/index.d.ts.map +1 -1
  35. package/lib/legend-ai/index.js +5 -2
  36. package/lib/legend-ai/index.js.map +1 -1
  37. package/lib/legend-ai/stores/LegendAIChatState.d.ts +12 -5
  38. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  39. package/lib/legend-ai/stores/LegendAIChatState.js +604 -69
  40. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  41. package/package.json +5 -5
  42. package/src/code-editor/CodeEditor.tsx +19 -0
  43. package/src/legend-ai/LegendAITypes.ts +51 -1
  44. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +169 -0
  45. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +9 -0
  46. package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +102 -0
  47. package/src/legend-ai/components/LegendAIAnalysisUtils.ts +226 -0
  48. package/src/legend-ai/components/LegendAICharts.tsx +166 -0
  49. package/src/legend-ai/components/LegendAIChat.tsx +74 -26
  50. package/src/legend-ai/index.ts +18 -0
  51. package/src/legend-ai/stores/LegendAIChatState.ts +1039 -128
  52. package/tsconfig.json +3 -0
@@ -25,16 +25,21 @@ import {
25
25
  type LegendAIMessage,
26
26
  type LegendAIConversationTurn,
27
27
  type LegendAIProductMetadata,
28
+ type LegendAIFallbackAction,
28
29
  LegendAIQuestionIntent,
29
30
  LegendAIThinkingStepStatus,
30
31
  LegendAIMessageRole,
32
+ LegendAIErrorType,
33
+ LegendAIServiceError,
31
34
  TDSServiceSourceType,
32
35
  buildColumnDefsFromNames,
36
+ LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
33
37
  } from '../LegendAITypes.js';
34
38
  import {
35
39
  type LegendAI_LegendApplicationPlugin_Extension,
36
40
  type LegendAIOrchestratorDataProductCoordinates,
37
41
  type LegendAISqlExecutionResultData,
42
+ type LegendAIResolvedEntities,
38
43
  LegendAIJudgeVerdict,
39
44
  } from '../LegendAI_LegendApplicationPlugin_Extension.js';
40
45
  import type { QueryExplicitExecutionContextInfo } from '@finos/legend-graph';
@@ -42,9 +47,32 @@ import type { QueryExplicitExecutionContextInfo } from '@finos/legend-graph';
42
47
  const MAX_ERROR_MESSAGE_LENGTH = 500;
43
48
  const MAX_THINKING_ERROR_PREVIEW_LENGTH = 200;
44
49
  const DEFAULT_MAX_JUDGE_ATTEMPTS = 5;
50
+ const DEFAULT_MAX_EXECUTION_RETRIES = 3;
51
+ const ANALYSIS_TIMEOUT_MS = 15_000;
45
52
 
46
53
  const SUGGESTED_QUERIES_DELIMITER = '---SUGGESTED_QUERIES---';
47
54
 
55
+ export function elapsedSeconds(startTime: number, decimals: 1 | 2 = 1): string {
56
+ return ((Date.now() - startTime) / 1000).toFixed(decimals);
57
+ }
58
+
59
+ function withTimeout<T>(
60
+ promise: Promise<T>,
61
+ ms: number,
62
+ ): Promise<T | undefined> {
63
+ let timer: ReturnType<typeof setTimeout> | undefined;
64
+ return Promise.race([
65
+ promise.finally(() => {
66
+ if (timer !== undefined) {
67
+ clearTimeout(timer);
68
+ }
69
+ }),
70
+ new Promise<undefined>((resolve) => {
71
+ timer = setTimeout(() => resolve(undefined), ms);
72
+ }),
73
+ ]);
74
+ }
75
+
48
76
  function deduplicateColumns(columns: string[]): string[] {
49
77
  const seen = new Map<string, number>();
50
78
  return columns.map((col) => {
@@ -58,7 +86,7 @@ export type MessageSetter = React.Dispatch<
58
86
  React.SetStateAction<LegendAIMessage[]>
59
87
  >;
60
88
 
61
- function createMessagePair(
89
+ export function createMessagePair(
62
90
  text: string,
63
91
  ): [LegendAIUserMessage, LegendAIAssistantMessage] {
64
92
  return [
@@ -69,19 +97,22 @@ function createMessagePair(
69
97
  thinkingSteps: [],
70
98
  sql: null,
71
99
  textAnswer: null,
100
+ dataContext: null,
72
101
  gridData: null,
73
102
  error: null,
103
+ errorType: null,
74
104
  sqlGenTime: null,
75
105
  execTime: null,
76
106
  thinkingDuration: null,
77
107
  isProcessing: true,
78
108
  isExecuting: false,
79
109
  suggestedQueries: [],
110
+ fallbackAction: null,
80
111
  },
81
112
  ];
82
113
  }
83
114
 
84
- interface LegendAIOperationContext {
115
+ export interface LegendAIOperationContext {
85
116
  config: LegendAIConfig;
86
117
  plugin: LegendAI_LegendApplicationPlugin_Extension;
87
118
  history: LegendAIConversationTurn[];
@@ -119,7 +150,7 @@ export function addThinkingStep(
119
150
  ? { ...s, status: LegendAIThinkingStepStatus.DONE }
120
151
  : s,
121
152
  ),
122
- { label, status: LegendAIThinkingStepStatus.ACTIVE },
153
+ { id: uuid(), label, status: LegendAIThinkingStepStatus.ACTIVE },
123
154
  ],
124
155
  }));
125
156
  }
@@ -134,10 +165,18 @@ export function completeThinkingSteps(setMessages: MessageSetter): void {
134
165
  }));
135
166
  }
136
167
 
168
+ export function classifyError(error: Error): LegendAIErrorType {
169
+ if (error instanceof LegendAIServiceError) {
170
+ return error.errorType;
171
+ }
172
+ return LegendAIErrorType.GENERAL;
173
+ }
174
+
137
175
  export function finishWithThinkingError(
138
176
  setMessages: MessageSetter,
139
177
  errorMsg: string,
140
178
  startTime: number,
179
+ errorType?: LegendAIErrorType,
141
180
  ): void {
142
181
  updateLastAssistant(setMessages, (msg) => ({
143
182
  thinkingSteps: msg.thinkingSteps.map((s) =>
@@ -146,8 +185,9 @@ export function finishWithThinkingError(
146
185
  : s,
147
186
  ),
148
187
  error: errorMsg.slice(0, MAX_ERROR_MESSAGE_LENGTH),
188
+ errorType: errorType ?? null,
149
189
  isProcessing: false,
150
- thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
190
+ thinkingDuration: elapsedSeconds(startTime),
151
191
  }));
152
192
  }
153
193
 
@@ -306,7 +346,7 @@ export async function handleMetadataQuestion(
306
346
  textAnswer: answer,
307
347
  suggestedQueries,
308
348
  isProcessing: false,
309
- thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
349
+ thinkingDuration: elapsedSeconds(startTime),
310
350
  }));
311
351
  }
312
352
 
@@ -340,6 +380,7 @@ export async function generateAndJudgeSql(
340
380
  setMessages,
341
381
  buildGenerationFailureMessage(failure, suggestion, services),
342
382
  startTime,
383
+ LegendAIErrorType.GENERATION,
343
384
  );
344
385
  return null;
345
386
  }
@@ -350,6 +391,7 @@ export async function generateAndJudgeSql(
350
391
  setMessages,
351
392
  'Could not extract SQL from LLM response.\nTry rephrasing your question or ask about a specific service.',
352
393
  startTime,
394
+ LegendAIErrorType.GENERATION,
353
395
  );
354
396
  return null;
355
397
  }
@@ -419,10 +461,10 @@ function reportExecutionResult(
419
461
 
420
462
  updateLastAssistant(setMessages, () => ({
421
463
  gridData: { columnDefs: buildColumnDefsFromNames(columns), rowData: rows },
422
- execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
464
+ execTime: elapsedSeconds(execStartTime, 2),
423
465
  isProcessing: false,
424
466
  isExecuting: false,
425
- thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
467
+ thinkingDuration: elapsedSeconds(startTime),
426
468
  }));
427
469
  return { columns, rows };
428
470
  }
@@ -453,6 +495,7 @@ export async function executeSqlAndReport(
453
495
  );
454
496
  } catch (executeError) {
455
497
  assertErrorThrown(executeError);
498
+ const execErrorType = classifyError(executeError);
456
499
  addThinkingStep(
457
500
  setMessages,
458
501
  `Execution failed: ${executeError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
@@ -461,9 +504,12 @@ export async function executeSqlAndReport(
461
504
  setMessages,
462
505
  buildExecutionErrorMessage(executeError.message, services),
463
506
  startTime,
507
+ execErrorType === LegendAIErrorType.GENERAL
508
+ ? LegendAIErrorType.EXECUTION
509
+ : execErrorType,
464
510
  );
465
511
  updateLastAssistant(setMessages, () => ({
466
- execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
512
+ execTime: elapsedSeconds(execStartTime, 2),
467
513
  isExecuting: false,
468
514
  }));
469
515
  return undefined;
@@ -496,44 +542,183 @@ export async function executePureQueryAndReport(
496
542
  );
497
543
  } catch (executeError) {
498
544
  assertErrorThrown(executeError);
545
+ const execErrorType = classifyError(executeError);
499
546
  addThinkingStep(
500
547
  setMessages,
501
548
  `Execution failed: ${executeError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
502
549
  );
503
550
  completeThinkingSteps(setMessages);
504
551
  updateLastAssistant(setMessages, () => ({
505
- execTime: ((Date.now() - execStartTime) / 1000).toFixed(2),
552
+ execTime: elapsedSeconds(execStartTime, 2),
506
553
  isExecuting: false,
507
554
  isProcessing: false,
508
555
  error: `Execution failed: ${executeError.message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}`,
509
- thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
556
+ errorType:
557
+ execErrorType === LegendAIErrorType.GENERAL
558
+ ? LegendAIErrorType.EXECUTION
559
+ : execErrorType,
560
+ thinkingDuration: elapsedSeconds(startTime),
510
561
  }));
511
562
  return { columns: [], rows: [] };
512
563
  }
513
564
  }
514
565
 
566
+ export async function analyzeOrchestratorResults(
567
+ question: string,
568
+ query: string,
569
+ execResult: LegendAISqlExecutionResultData,
570
+ metadata: LegendAIProductMetadata,
571
+ context: LegendAIOperationContext,
572
+ startTime: number,
573
+ ): Promise<void> {
574
+ const { config, plugin, setMessages } = context;
575
+ addThinkingStep(setMessages, 'Analyzing results...');
576
+ updateLastAssistant(setMessages, () => ({
577
+ isProcessing: true,
578
+ }));
579
+ const analysis = await withTimeout(
580
+ plugin.analyzeQueryResults(
581
+ question,
582
+ query,
583
+ execResult.columns,
584
+ execResult.rows,
585
+ metadata,
586
+ config,
587
+ ),
588
+ ANALYSIS_TIMEOUT_MS,
589
+ );
590
+ if (analysis) {
591
+ completeThinkingSteps(setMessages);
592
+ updateLastAssistant(setMessages, () => ({
593
+ textAnswer: analysis.summary,
594
+ suggestedQueries: analysis.suggestedQueries,
595
+ isProcessing: false,
596
+ thinkingDuration: elapsedSeconds(startTime),
597
+ }));
598
+ }
599
+ }
600
+
601
+ async function handleEmptyOrchestratorResults(
602
+ question: string,
603
+ legendQuery: string,
604
+ orchestratorOptions: Required<LegendAIOrchestratorOptionsParam>,
605
+ metadata: LegendAIProductMetadata,
606
+ resolvedEntities: LegendAIResolvedEntities,
607
+ context: LegendAIOperationContext,
608
+ startTime: number,
609
+ ): Promise<void> {
610
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
611
+ const { config, plugin, setMessages } = context;
612
+
613
+ if (resolvedEntities.relatedEntities.length > 0) {
614
+ const alternateRoot = resolvedEntities.relatedEntities[0];
615
+ if (alternateRoot) {
616
+ addThinkingStep(
617
+ setMessages,
618
+ `No results with ${resolvedEntities.rootEntity.split('::').pop() ?? resolvedEntities.rootEntity}, retrying with ${alternateRoot.split('::').pop() ?? alternateRoot}...`,
619
+ );
620
+
621
+ try {
622
+ const retryResponse = await plugin.generateQueryViaOrchestrator(
623
+ {
624
+ user_question: question,
625
+ semantic_search_resolution_details: {
626
+ data_product_coordinates: dataProductCoordinates,
627
+ root_entity: alternateRoot,
628
+ related_entities: resolvedEntities.relatedEntities.slice(1),
629
+ },
630
+ },
631
+ config,
632
+ );
633
+
634
+ updateLastAssistant(setMessages, () => ({
635
+ sql: retryResponse.legend_query,
636
+ sqlGenTime: elapsedSeconds(startTime, 2),
637
+ isExecuting: true,
638
+ }));
639
+
640
+ const retryResult = await executePureQueryAndReport(
641
+ retryResponse.legend_query,
642
+ pureExecutionContext,
643
+ dataProductCoordinates,
644
+ config,
645
+ plugin,
646
+ setMessages,
647
+ startTime,
648
+ );
649
+
650
+ if (retryResult.rows.length > 0) {
651
+ await analyzeOrchestratorResults(
652
+ question,
653
+ retryResponse.legend_query,
654
+ retryResult,
655
+ metadata,
656
+ context,
657
+ startTime,
658
+ );
659
+ return;
660
+ }
661
+ } catch {
662
+ /* empty */
663
+ }
664
+ }
665
+ }
666
+
667
+ addThinkingStep(
668
+ setMessages,
669
+ 'No results returned \u2014 building contextual guidance...',
670
+ );
671
+ updateLastAssistant(setMessages, () => ({
672
+ isProcessing: true,
673
+ }));
674
+ const fallback = await withTimeout(
675
+ plugin.buildNoResultsFallback(question, legendQuery, metadata, config),
676
+ ANALYSIS_TIMEOUT_MS,
677
+ );
678
+ if (fallback) {
679
+ completeThinkingSteps(setMessages);
680
+ updateLastAssistant(setMessages, () => ({
681
+ textAnswer: fallback.summary,
682
+ suggestedQueries: fallback.suggestedQueries,
683
+ isProcessing: false,
684
+ thinkingDuration: elapsedSeconds(startTime),
685
+ }));
686
+ }
687
+ }
688
+
515
689
  export async function processQuestionViaOrchestrator(
516
690
  question: string,
517
691
  dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
518
- _metadata: LegendAIProductMetadata,
692
+ metadata: LegendAIProductMetadata,
519
693
  context: LegendAIOperationContext,
520
694
  pureExecutionContext?: QueryExplicitExecutionContextInfo,
695
+ preResolvedEntities?: LegendAIResolvedEntities,
521
696
  ): Promise<void> {
522
697
  const { config, plugin, setMessages } = context;
523
698
  const startTime = Date.now();
524
699
 
525
700
  try {
526
- addThinkingStep(setMessages, 'Resolving entities for your query...');
527
- const resolvedEntities = await plugin.resolveEntitiesForQuery(
528
- question,
529
- dataProductCoordinates,
530
- config,
531
- );
701
+ let resolvedEntities: LegendAIResolvedEntities;
702
+ if (preResolvedEntities) {
703
+ resolvedEntities = preResolvedEntities;
704
+ addThinkingStep(
705
+ setMessages,
706
+ `Using pre-resolved root entity: ${resolvedEntities.rootEntity.split('::').pop() ?? resolvedEntities.rootEntity}`,
707
+ );
708
+ } else {
709
+ addThinkingStep(setMessages, 'Resolving entities for your query...');
710
+ resolvedEntities = await plugin.resolveEntitiesForQuery(
711
+ question,
712
+ dataProductCoordinates,
713
+ config,
714
+ pureExecutionContext,
715
+ );
716
+ addThinkingStep(
717
+ setMessages,
718
+ `Found root entity: ${resolvedEntities.rootEntity.split('::').pop() ?? resolvedEntities.rootEntity}`,
719
+ );
720
+ }
532
721
 
533
- addThinkingStep(
534
- setMessages,
535
- `Found root entity: ${resolvedEntities.rootEntity.split('::').pop() ?? resolvedEntities.rootEntity}`,
536
- );
537
722
  if (resolvedEntities.relatedEntities.length > 0) {
538
723
  addThinkingStep(
539
724
  setMessages,
@@ -554,7 +739,7 @@ export async function processQuestionViaOrchestrator(
554
739
  config,
555
740
  );
556
741
 
557
- const queryGenTime = ((Date.now() - startTime) / 1000).toFixed(2);
742
+ const queryGenTime = elapsedSeconds(startTime, 2);
558
743
  completeThinkingSteps(setMessages);
559
744
  updateLastAssistant(setMessages, () => ({
560
745
  sql: orchestratorResponse.legend_query,
@@ -569,12 +754,13 @@ export async function processQuestionViaOrchestrator(
569
754
  isExecuting: false,
570
755
  error:
571
756
  'No execution context available — cannot execute query via engine.',
572
- thinkingDuration: ((Date.now() - startTime) / 1000).toFixed(1),
757
+ errorType: LegendAIErrorType.EXECUTION,
758
+ thinkingDuration: elapsedSeconds(startTime),
573
759
  }));
574
760
  return;
575
761
  }
576
762
 
577
- await executePureQueryAndReport(
763
+ const execResult = await executePureQueryAndReport(
578
764
  orchestratorResponse.legend_query,
579
765
  pureExecutionContext,
580
766
  dataProductCoordinates,
@@ -583,82 +769,633 @@ export async function processQuestionViaOrchestrator(
583
769
  setMessages,
584
770
  startTime,
585
771
  );
772
+
773
+ try {
774
+ if (execResult.rows.length > 0) {
775
+ await analyzeOrchestratorResults(
776
+ question,
777
+ orchestratorResponse.legend_query,
778
+ execResult,
779
+ metadata,
780
+ context,
781
+ startTime,
782
+ );
783
+ } else {
784
+ await handleEmptyOrchestratorResults(
785
+ question,
786
+ orchestratorResponse.legend_query,
787
+ { dataProductCoordinates, pureExecutionContext },
788
+ metadata,
789
+ resolvedEntities,
790
+ context,
791
+ startTime,
792
+ );
793
+ }
794
+ } catch {
795
+ /* empty */
796
+ } finally {
797
+ completeThinkingSteps(setMessages);
798
+ updateLastAssistant(setMessages, () => ({
799
+ isProcessing: false,
800
+ thinkingDuration: elapsedSeconds(startTime),
801
+ }));
802
+ }
586
803
  } catch (error) {
587
804
  assertErrorThrown(error);
805
+ const orchErrorType = classifyError(error);
588
806
  addThinkingStep(
589
807
  setMessages,
590
808
  `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
591
809
  );
592
- finishWithThinkingError(setMessages, error.message, startTime);
810
+
811
+ try {
812
+ addThinkingStep(
813
+ setMessages,
814
+ 'Building guidance from available metadata...',
815
+ );
816
+ const fallbackText = await withTimeout(
817
+ plugin.buildFailureFallback(question, error.message, metadata, config),
818
+ ANALYSIS_TIMEOUT_MS,
819
+ );
820
+ if (fallbackText) {
821
+ completeThinkingSteps(setMessages);
822
+ updateLastAssistant(setMessages, () => ({
823
+ dataContext: fallbackText,
824
+ isProcessing: false,
825
+ thinkingDuration: elapsedSeconds(startTime),
826
+ }));
827
+ return;
828
+ }
829
+ } catch {
830
+ /* empty */
831
+ }
832
+
833
+ finishWithThinkingError(
834
+ setMessages,
835
+ error.message,
836
+ startTime,
837
+ orchErrorType,
838
+ );
593
839
  }
594
840
  }
595
841
 
596
- async function processDataQuery(
842
+ function cleanLlmSqlResponse(raw: string): string {
843
+ return raw
844
+ .trim()
845
+ .replace(/^```\w*\n?/, '')
846
+ .replace(/\n?```$/, '')
847
+ .replace(/;\s*$/, '')
848
+ .trim();
849
+ }
850
+
851
+ function isValidSqlCorrection(trimmed: string, currentSql: string): boolean {
852
+ return (
853
+ trimmed.length > 0 &&
854
+ trimmed.toLowerCase().startsWith('select') &&
855
+ trimmed !== currentSql
856
+ );
857
+ }
858
+
859
+ const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
860
+
861
+ const JOIN_PATTERN = /\bJOIN\b/i;
862
+
863
+ const ORDER_BY_SPLIT = /\bORDER\s+BY\b/i;
864
+
865
+ export function sanitizeJoinOrderBy(sql: string): string {
866
+ if (!JOIN_PATTERN.test(sql)) {
867
+ return sql;
868
+ }
869
+ const parts = sql.split(ORDER_BY_SPLIT);
870
+ if (parts.length < 2) {
871
+ return sql;
872
+ }
873
+
874
+ const beforeOrderBy = parts[0] ?? '';
875
+ const afterOrderBy = parts.slice(1).join('ORDER BY').replace(/^\s+/, '');
876
+
877
+ const selectAliases = new Map<string, string>();
878
+ const aliasRegex =
879
+ /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
880
+ let m: RegExpExecArray | null;
881
+ while ((m = aliasRegex.exec(beforeOrderBy)) !== null) {
882
+ const tableAlias = (m.groups?.tbl ?? '').toLowerCase();
883
+ const colName = (m.groups?.col ?? '').toLowerCase();
884
+ const asAlias = m.groups?.qAlias ?? m.groups?.uAlias ?? '';
885
+ selectAliases.set(`${tableAlias}.${colName}`, asAlias);
886
+ }
887
+
888
+ if (selectAliases.size === 0) {
889
+ return sql;
890
+ }
891
+
892
+ const rewritten = afterOrderBy.replaceAll(
893
+ ALIAS_DOT_COL_PATTERN,
894
+ (...args) => {
895
+ const groups = args[args.length - 1] as {
896
+ tbl: string;
897
+ col: string;
898
+ };
899
+ const key = `${groups.tbl.toLowerCase()}.${groups.col.toLowerCase()}`;
900
+ const alias = selectAliases.get(key);
901
+ return alias ? `"${alias}"` : String(args[0]);
902
+ },
903
+ );
904
+
905
+ if (rewritten === afterOrderBy) {
906
+ return sql;
907
+ }
908
+ return `${beforeOrderBy}ORDER BY ${rewritten}`;
909
+ }
910
+
911
+ const UNION_ALL_PATTERN = /\bUNION\s+ALL\b/i;
912
+
913
+ const LITERAL_COL_PATTERN = /,\s*'[^']*'\s+AS\s+(?:"[^"]+"|[a-z]\w*)/gi;
914
+
915
+ export function sanitizeLiteralColumns(sql: string): string {
916
+ if (!UNION_ALL_PATTERN.test(sql)) {
917
+ return sql;
918
+ }
919
+ LITERAL_COL_PATTERN.lastIndex = 0;
920
+ if (!LITERAL_COL_PATTERN.test(sql)) {
921
+ return sql;
922
+ }
923
+ LITERAL_COL_PATTERN.lastIndex = 0;
924
+ return sql.replace(LITERAL_COL_PATTERN, '');
925
+ }
926
+
927
+ const SERVICE_PARAM_DATE_LIKE =
928
+ /date|time|day|month|year|period|asOf|businessDate|processingDate|snapshot/i;
929
+
930
+ function hasUnresolvableParams(service: TDSServiceSchema): boolean {
931
+ return service.parameters.some((p) => !SERVICE_PARAM_DATE_LIKE.test(p));
932
+ }
933
+
934
+ function getNonDateParamNames(service: TDSServiceSchema): string[] {
935
+ return service.parameters.filter((p) => !SERVICE_PARAM_DATE_LIKE.test(p));
936
+ }
937
+
938
+ export function stripNonDateServiceParams(sql: string): string {
939
+ return sql.replaceAll(/,\s*\w+\s*=>\s*'[^']*'/g, (match) => {
940
+ const paramName = /,\s*(?<param>\w+)\s*=>/.exec(match)?.groups?.param;
941
+ if (!paramName) {
942
+ return match;
943
+ }
944
+ if (
945
+ paramName === 'coordinates' ||
946
+ SERVICE_PARAM_DATE_LIKE.test(paramName)
947
+ ) {
948
+ return match;
949
+ }
950
+ return '';
951
+ });
952
+ }
953
+
954
+ async function executeSqlForServices(
955
+ sql: string,
956
+ services: TDSServiceSchema[],
957
+ dataProductCoordinates:
958
+ | LegendAIOrchestratorDataProductCoordinates
959
+ | undefined,
960
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
961
+ config: LegendAIConfig,
962
+ ): Promise<LegendAISqlExecutionResultData> {
963
+ const safeSql = sanitizeLiteralColumns(sanitizeJoinOrderBy(sql));
964
+ const isAccessPoint = services.some(
965
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
966
+ );
967
+ if (isAccessPoint && dataProductCoordinates) {
968
+ return plugin.executeLakehouseSql(safeSql, dataProductCoordinates, config);
969
+ }
970
+ return plugin.executeSql(safeSql, config);
971
+ }
972
+
973
+ interface SqlExecutionOutcome {
974
+ sql: string;
975
+ result?: LegendAISqlExecutionResultData;
976
+ error?: string;
977
+ }
978
+
979
+ async function executeSqlWithRetries(
980
+ initialSql: string,
981
+ question: string,
982
+ services: TDSServiceSchema[],
983
+ coordinates: string,
984
+ dataProductCoordinates:
985
+ | LegendAIOrchestratorDataProductCoordinates
986
+ | undefined,
987
+ context: LegendAIOperationContext,
988
+ ): Promise<SqlExecutionOutcome> {
989
+ const { plugin, config, setMessages } = context;
990
+ let currentSql = initialSql;
991
+
992
+ for (let attempt = 0; attempt <= DEFAULT_MAX_EXECUTION_RETRIES; attempt++) {
993
+ try {
994
+ const result = await executeSqlForServices(
995
+ currentSql,
996
+ services,
997
+ dataProductCoordinates,
998
+ plugin,
999
+ config,
1000
+ );
1001
+ return { sql: currentSql, result };
1002
+ } catch (executeError) {
1003
+ assertErrorThrown(executeError);
1004
+ if (attempt >= DEFAULT_MAX_EXECUTION_RETRIES) {
1005
+ return { sql: currentSql, error: executeError.message };
1006
+ }
1007
+ addThinkingStep(
1008
+ setMessages,
1009
+ `Execution failed (attempt ${attempt + 1}/${DEFAULT_MAX_EXECUTION_RETRIES + 1}), correcting query...`,
1010
+ );
1011
+ const corrected = await attemptErrorCorrection(
1012
+ currentSql,
1013
+ executeError.message,
1014
+ question,
1015
+ services,
1016
+ coordinates,
1017
+ plugin,
1018
+ config,
1019
+ );
1020
+ if (corrected) {
1021
+ currentSql = corrected;
1022
+ updateLastAssistant(setMessages, () => ({ sql: currentSql }));
1023
+ continue;
1024
+ }
1025
+ return { sql: currentSql, error: executeError.message };
1026
+ }
1027
+ }
1028
+ return { sql: currentSql };
1029
+ }
1030
+
1031
+ async function attemptErrorCorrection(
1032
+ currentSql: string,
1033
+ errorMessage: string,
1034
+ question: string,
1035
+ services: TDSServiceSchema[],
1036
+ coordinates: string,
1037
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
1038
+ config: LegendAIConfig,
1039
+ ): Promise<string | undefined> {
1040
+ const prompt = plugin.buildErrorCorrectionPrompt(
1041
+ currentSql,
1042
+ errorMessage,
1043
+ question,
1044
+ services,
1045
+ coordinates,
1046
+ );
1047
+ if (!prompt) {
1048
+ return undefined;
1049
+ }
1050
+ try {
1051
+ const correctedSql = await plugin.callLLM(prompt, config);
1052
+ const trimmed = cleanLlmSqlResponse(correctedSql);
1053
+ if (isValidSqlCorrection(trimmed, currentSql)) {
1054
+ return trimmed;
1055
+ }
1056
+ } catch {
1057
+ /* empty */
1058
+ }
1059
+ return undefined;
1060
+ }
1061
+
1062
+ async function attemptZeroRowCorrection(
1063
+ currentSql: string,
597
1064
  question: string,
598
1065
  services: TDSServiceSchema[],
599
1066
  coordinates: string,
1067
+ dataProductCoordinates:
1068
+ | LegendAIOrchestratorDataProductCoordinates
1069
+ | undefined,
1070
+ context: LegendAIOperationContext,
1071
+ ): Promise<
1072
+ { sql: string; result: LegendAISqlExecutionResultData } | undefined
1073
+ > {
1074
+ const { plugin, config, setMessages } = context;
1075
+ addThinkingStep(
1076
+ setMessages,
1077
+ 'Query returned 0 rows, attempting filter correction...',
1078
+ );
1079
+ const prompt = plugin.buildZeroRowCorrectionPrompt(
1080
+ currentSql,
1081
+ question,
1082
+ services,
1083
+ coordinates,
1084
+ );
1085
+ if (!prompt) {
1086
+ return undefined;
1087
+ }
1088
+ try {
1089
+ const correctedSql = await plugin.callLLM(prompt, config);
1090
+ const trimmed = cleanLlmSqlResponse(correctedSql);
1091
+ if (!isValidSqlCorrection(trimmed, currentSql)) {
1092
+ return undefined;
1093
+ }
1094
+ addThinkingStep(setMessages, 'Retrying with corrected filters...');
1095
+ updateLastAssistant(setMessages, () => ({ sql: trimmed }));
1096
+ try {
1097
+ const retryResult = await executeSqlForServices(
1098
+ trimmed,
1099
+ services,
1100
+ dataProductCoordinates,
1101
+ plugin,
1102
+ config,
1103
+ );
1104
+ if (retryResult.rows.length > 0) {
1105
+ return { sql: trimmed, result: retryResult };
1106
+ }
1107
+ } catch {
1108
+ /* empty */
1109
+ }
1110
+ } catch {
1111
+ /* empty */
1112
+ }
1113
+ return undefined;
1114
+ }
1115
+
1116
+ function buildZeroRowMessage(services: TDSServiceSchema[]): string {
1117
+ const withUnresolvable = services.filter((s) => hasUnresolvableParams(s));
1118
+ if (withUnresolvable.length > 0) {
1119
+ const parts: string[] = [];
1120
+ for (const svc of withUnresolvable) {
1121
+ for (const paramName of getNonDateParamNames(svc)) {
1122
+ const matchingCol = svc.columns.find((c) => c.name === paramName);
1123
+ const docHint = matchingCol?.documentation ?? matchingCol?.sampleValues;
1124
+ if (docHint) {
1125
+ parts.push(`**${paramName}** (${docHint})`);
1126
+ } else {
1127
+ parts.push(`**${paramName}**`);
1128
+ }
1129
+ }
1130
+ }
1131
+ const uniqueParts = [...new Set(parts)];
1132
+ const firstSvc = withUnresolvable[0];
1133
+ const firstParam = firstSvc
1134
+ ? (getNonDateParamNames(firstSvc)[0] ?? 'parameter')
1135
+ : 'parameter';
1136
+ return `The SQL query executed successfully but returned **0 rows**. This service requires specific values for ${uniqueParts.join(', ')} to return data. Please include ${uniqueParts.length === 1 ? 'a value' : 'values'} in your question, e.g., "show data where ${firstParam} is [your value]".`;
1137
+ }
1138
+ return 'The SQL query executed successfully but returned **0 rows**. The applied filters may not match any records, or the specific values may not exist in the queried datasets.';
1139
+ }
1140
+
1141
+ function offerOrchestratorFallbackMessage(
1142
+ setMessages: MessageSetter,
1143
+ startTime: number,
1144
+ fallbackMessage: string,
1145
+ ): void {
1146
+ updateLastAssistant(setMessages, () => ({
1147
+ textAnswer: fallbackMessage,
1148
+ fallbackAction: {
1149
+ label: 'Try Legend AI Orchestrator',
1150
+ actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
1151
+ },
1152
+ isProcessing: false,
1153
+ thinkingDuration: elapsedSeconds(startTime),
1154
+ }));
1155
+ }
1156
+
1157
+ function reportFatalQueryError(
1158
+ setMessages: MessageSetter,
1159
+ startTime: number,
1160
+ errorMessage: string,
1161
+ errorType: LegendAIErrorType,
1162
+ ): void {
1163
+ finishWithThinkingError(setMessages, errorMessage, startTime, errorType);
1164
+ }
1165
+
1166
+ function handleSqlGenerationFailure(
1167
+ setMessages: MessageSetter,
1168
+ startTime: number,
1169
+ hasOrchestratorFallback: boolean,
1170
+ orchestratorMessage: string,
1171
+ errorMessage: string,
1172
+ errorType: LegendAIErrorType,
1173
+ ): void {
1174
+ completeThinkingSteps(setMessages);
1175
+ if (hasOrchestratorFallback) {
1176
+ offerOrchestratorFallbackMessage(
1177
+ setMessages,
1178
+ startTime,
1179
+ orchestratorMessage,
1180
+ );
1181
+ } else {
1182
+ reportFatalQueryError(setMessages, startTime, errorMessage, errorType);
1183
+ }
1184
+ }
1185
+
1186
+ interface QueryResultReport {
1187
+ currentSql: string;
1188
+ sqlResult: LegendAISqlExecutionResultData;
1189
+ question: string;
1190
+ services: TDSServiceSchema[];
1191
+ }
1192
+
1193
+ async function reportQueryResults(
1194
+ report: QueryResultReport,
600
1195
  metadata: LegendAIProductMetadata,
601
1196
  context: LegendAIOperationContext,
602
1197
  startTime: number,
603
- orchestratorOptions?: LegendAIOrchestratorOptionsParam,
1198
+ hasOrchestratorFallback: boolean,
604
1199
  ): Promise<void> {
605
- const { config, plugin, setMessages } = context;
606
- const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
607
- const pureExecutionContext = orchestratorOptions?.pureExecutionContext;
1200
+ const { currentSql, sqlResult, question, services } = report;
1201
+ const { setMessages } = context;
1202
+ if (sqlResult.rows.length > 0) {
1203
+ const columns = deduplicateColumns(sqlResult.columns);
1204
+ const rows = sqlResult.rows;
1205
+ completeThinkingSteps(setMessages);
1206
+ addThinkingStep(
1207
+ setMessages,
1208
+ `Retrieved ${rows.length} row${rows.length === 1 ? '' : 's'}`,
1209
+ );
1210
+ completeThinkingSteps(setMessages);
1211
+ updateLastAssistant(setMessages, () => ({
1212
+ sql: currentSql,
1213
+ gridData: {
1214
+ columnDefs: buildColumnDefsFromNames(columns),
1215
+ rowData: rows,
1216
+ },
1217
+ execTime: elapsedSeconds(startTime, 2),
1218
+ isProcessing: true,
1219
+ isExecuting: false,
1220
+ thinkingDuration: elapsedSeconds(startTime),
1221
+ }));
608
1222
 
609
- if (services.length === 0) {
610
- if (config.orchestratorUrl && dataProductCoordinates) {
611
- completeThinkingSteps(setMessages);
612
- await processQuestionViaOrchestrator(
1223
+ try {
1224
+ await analyzeOrchestratorResults(
613
1225
  question,
614
- dataProductCoordinates,
1226
+ currentSql,
1227
+ sqlResult,
615
1228
  metadata,
616
1229
  context,
617
- pureExecutionContext,
1230
+ startTime,
618
1231
  );
619
- return;
1232
+ } catch {
1233
+ /* empty */
1234
+ } finally {
1235
+ completeThinkingSteps(setMessages);
1236
+ updateLastAssistant(setMessages, () => ({
1237
+ isProcessing: false,
1238
+ thinkingDuration: elapsedSeconds(startTime),
1239
+ }));
620
1240
  }
621
- finishWithThinkingError(
1241
+ } else {
1242
+ addThinkingStep(
1243
+ setMessages,
1244
+ 'Query returned 0 rows after correction attempts.',
1245
+ );
1246
+ completeThinkingSteps(setMessages);
1247
+ const fallback = hasOrchestratorFallback
1248
+ ? {
1249
+ fallbackAction: {
1250
+ label: 'Try Legend AI Orchestrator',
1251
+ actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
1252
+ } as LegendAIFallbackAction,
1253
+ }
1254
+ : {};
1255
+ updateLastAssistant(setMessages, () => ({
1256
+ textAnswer: buildZeroRowMessage(services),
1257
+ ...fallback,
1258
+ isProcessing: false,
1259
+ isExecuting: false,
1260
+ thinkingDuration: elapsedSeconds(startTime),
1261
+ }));
1262
+ }
1263
+ }
1264
+
1265
+ async function selectBestServices(
1266
+ question: string,
1267
+ services: TDSServiceSchema[],
1268
+ context: LegendAIOperationContext,
1269
+ ): Promise<TDSServiceSchema[]> {
1270
+ const { plugin, config, setMessages } = context;
1271
+ if (services.length <= 1) {
1272
+ return services;
1273
+ }
1274
+ try {
1275
+ addThinkingStep(setMessages, 'Selecting best service for your query...');
1276
+ return await plugin.selectRelevantServices(question, services, config);
1277
+ } catch {
1278
+ return services;
1279
+ }
1280
+ }
1281
+
1282
+ async function tryRecoverZeroRows(
1283
+ currentSql: string,
1284
+ sqlResult: LegendAISqlExecutionResultData,
1285
+ question: string,
1286
+ selectedServices: TDSServiceSchema[],
1287
+ coordinates: string,
1288
+ dataProductCoordinates:
1289
+ | LegendAIOrchestratorDataProductCoordinates
1290
+ | undefined,
1291
+ context: LegendAIOperationContext,
1292
+ ): Promise<{ sql: string; result: LegendAISqlExecutionResultData }> {
1293
+ const { plugin, config, setMessages } = context;
1294
+ let recoveredSql = currentSql;
1295
+ let recoveredResult = sqlResult;
1296
+
1297
+ const strippedSql = stripNonDateServiceParams(recoveredSql);
1298
+ if (strippedSql !== recoveredSql) {
1299
+ addThinkingStep(
1300
+ setMessages,
1301
+ 'Trying query without guessed parameter values...',
1302
+ );
1303
+ try {
1304
+ const strippedResult = await executeSqlForServices(
1305
+ strippedSql,
1306
+ selectedServices,
1307
+ dataProductCoordinates,
1308
+ plugin,
1309
+ config,
1310
+ );
1311
+ if (strippedResult.rows.length > 0) {
1312
+ recoveredSql = strippedSql;
1313
+ recoveredResult = strippedResult;
1314
+ updateLastAssistant(setMessages, () => ({ sql: strippedSql }));
1315
+ }
1316
+ } catch {
1317
+ /* empty */
1318
+ }
1319
+ }
1320
+
1321
+ if (recoveredResult.rows.length === 0) {
1322
+ const correction = await attemptZeroRowCorrection(
1323
+ recoveredSql,
1324
+ question,
1325
+ selectedServices,
1326
+ coordinates,
1327
+ dataProductCoordinates,
1328
+ context,
1329
+ );
1330
+ if (correction) {
1331
+ recoveredSql = correction.sql;
1332
+ recoveredResult = correction.result;
1333
+ }
1334
+ }
1335
+
1336
+ return { sql: recoveredSql, result: recoveredResult };
1337
+ }
1338
+
1339
+ async function processDataQuery(
1340
+ question: string,
1341
+ services: TDSServiceSchema[],
1342
+ coordinates: string,
1343
+ metadata: LegendAIProductMetadata,
1344
+ context: LegendAIOperationContext,
1345
+ startTime: number,
1346
+ orchestratorOptions?: LegendAIOrchestratorOptionsParam,
1347
+ ): Promise<void> {
1348
+ const { config, setMessages } = context;
1349
+ const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
1350
+ const hasOrchestratorFallback = Boolean(
1351
+ config.orchestratorUrl && dataProductCoordinates,
1352
+ );
1353
+
1354
+ if (services.length === 0) {
1355
+ handleSqlGenerationFailure(
622
1356
  setMessages,
623
- 'No TDS services available for querying',
624
1357
  startTime,
1358
+ hasOrchestratorFallback,
1359
+ 'No TDS services available for SQL querying. You can try the Legend AI Orchestrator to generate a Pure query instead.',
1360
+ 'No TDS services available for querying',
1361
+ LegendAIErrorType.GENERAL,
625
1362
  );
626
1363
  return;
627
1364
  }
628
1365
 
629
1366
  addThinkingStep(setMessages, 'Found relevant services to query');
630
1367
 
631
- const judgedSql = await generateAndJudgeSql(
1368
+ const selectedServices = await selectBestServices(
632
1369
  question,
633
1370
  services,
1371
+ context,
1372
+ );
1373
+
1374
+ const judgedSql = await generateAndJudgeSql(
1375
+ question,
1376
+ selectedServices,
634
1377
  coordinates,
635
1378
  context,
636
1379
  startTime,
637
1380
  );
638
1381
 
639
1382
  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
- }
1383
+ addThinkingStep(
1384
+ setMessages,
1385
+ 'SQL generation could not produce a valid query.',
1386
+ );
1387
+ handleSqlGenerationFailure(
1388
+ setMessages,
1389
+ startTime,
1390
+ hasOrchestratorFallback,
1391
+ 'SQL generation could not handle this query. You can try the Legend AI Orchestrator to generate a Pure query instead.',
1392
+ 'SQL generation could not handle this query. Try rephrasing your question.',
1393
+ LegendAIErrorType.GENERATION,
1394
+ );
658
1395
  return;
659
1396
  }
660
1397
 
661
- const sqlGenTimeValue = ((Date.now() - startTime) / 1000).toFixed(2);
1398
+ const sqlGenTimeValue = elapsedSeconds(startTime, 2);
662
1399
  completeThinkingSteps(setMessages);
663
1400
  updateLastAssistant(setMessages, () => ({
664
1401
  sql: judgedSql,
@@ -666,39 +1403,76 @@ async function processDataQuery(
666
1403
  isExecuting: true,
667
1404
  }));
668
1405
 
669
- const sqlResult = await executeSqlAndReport(
1406
+ const execOutcome = await executeSqlWithRetries(
670
1407
  judgedSql,
671
- services,
672
- config,
673
- plugin,
674
- setMessages,
675
- startTime,
1408
+ question,
1409
+ selectedServices,
1410
+ coordinates,
676
1411
  dataProductCoordinates,
1412
+ context,
677
1413
  );
678
1414
 
679
- if (
680
- sqlResult?.rows.length === 0 &&
681
- config.orchestratorUrl &&
682
- dataProductCoordinates
683
- ) {
1415
+ if (execOutcome.error) {
1416
+ const execErrorType = classifyError(new Error(execOutcome.error));
684
1417
  addThinkingStep(
685
1418
  setMessages,
686
- 'SQL query returned no results, trying Legend AI orchestrator...',
1419
+ `Execution failed: ${execOutcome.error.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1420
+ );
1421
+ finishWithThinkingError(
1422
+ setMessages,
1423
+ buildExecutionErrorMessage(execOutcome.error, selectedServices),
1424
+ startTime,
1425
+ execErrorType === LegendAIErrorType.GENERAL
1426
+ ? LegendAIErrorType.EXECUTION
1427
+ : execErrorType,
687
1428
  );
688
1429
  updateLastAssistant(setMessages, () => ({
689
- gridData: null,
690
- error: null,
691
- isProcessing: true,
692
1430
  isExecuting: false,
1431
+ ...(hasOrchestratorFallback
1432
+ ? {
1433
+ fallbackAction: {
1434
+ label: 'Try Legend AI Orchestrator',
1435
+ actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
1436
+ } as LegendAIFallbackAction,
1437
+ }
1438
+ : {}),
693
1439
  }));
694
- await processQuestionViaOrchestrator(
1440
+ return;
1441
+ }
1442
+
1443
+ if (!execOutcome.result) {
1444
+ return;
1445
+ }
1446
+
1447
+ let currentSql = execOutcome.sql;
1448
+ let sqlResult = execOutcome.result;
1449
+
1450
+ if (sqlResult.rows.length === 0) {
1451
+ const recovered = await tryRecoverZeroRows(
1452
+ currentSql,
1453
+ sqlResult,
695
1454
  question,
1455
+ selectedServices,
1456
+ coordinates,
696
1457
  dataProductCoordinates,
697
- metadata,
698
1458
  context,
699
- pureExecutionContext,
700
1459
  );
1460
+ currentSql = recovered.sql;
1461
+ sqlResult = recovered.result;
701
1462
  }
1463
+
1464
+ await reportQueryResults(
1465
+ {
1466
+ currentSql,
1467
+ sqlResult,
1468
+ question,
1469
+ services: selectedServices,
1470
+ },
1471
+ metadata,
1472
+ context,
1473
+ startTime,
1474
+ hasOrchestratorFallback,
1475
+ );
702
1476
  }
703
1477
 
704
1478
  export async function processQuestion(
@@ -716,66 +1490,96 @@ export async function processQuestion(
716
1490
  try {
717
1491
  addThinkingStep(setMessages, 'Analyzing your question...');
718
1492
 
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(
1493
+ const orchestratorOpts = dataProductCoordinates
1494
+ ? {
1495
+ dataProductCoordinates,
1496
+ ...(pureExecutionContext === undefined
1497
+ ? {}
1498
+ : { pureExecutionContext }),
1499
+ }
1500
+ : undefined;
1501
+
1502
+ if (services.length > 0) {
1503
+ const serviceNames = services.map((s) => s.title);
1504
+ const intent = await plugin.classifyQuestionIntent(
729
1505
  question,
730
- metadata,
731
- context,
732
- startTime,
733
- services.length > 0,
1506
+ true,
1507
+ config,
1508
+ serviceNames,
734
1509
  );
735
- return;
736
- }
737
1510
 
738
- if (intent === LegendAIQuestionIntent.ORCHESTRATOR) {
739
- if (config.orchestratorUrl && dataProductCoordinates) {
740
- completeThinkingSteps(setMessages);
741
- await processQuestionViaOrchestrator(
1511
+ if (intent === LegendAIQuestionIntent.METADATA) {
1512
+ await handleMetadataQuestion(
742
1513
  question,
743
- dataProductCoordinates,
744
1514
  metadata,
745
1515
  context,
746
- pureExecutionContext,
1516
+ startTime,
1517
+ true,
747
1518
  );
748
1519
  return;
749
1520
  }
750
- addThinkingStep(
751
- setMessages,
752
- 'Orchestrator not available, trying SQL generation...',
753
- );
1521
+
1522
+ // DATA_QUERY or ORCHESTRATOR — try SQL generation.
1523
+ // If SQL throws, fall back to metadata as a safety net
1524
+ // (e.g. misclassified metadata question).
1525
+ try {
1526
+ await processDataQuery(
1527
+ question,
1528
+ services,
1529
+ coordinates,
1530
+ metadata,
1531
+ context,
1532
+ startTime,
1533
+ orchestratorOpts,
1534
+ );
1535
+ } catch (sqlError) {
1536
+ assertErrorThrown(sqlError);
1537
+ addThinkingStep(
1538
+ setMessages,
1539
+ 'SQL generation failed, answering from product metadata...',
1540
+ );
1541
+ await handleMetadataQuestion(
1542
+ question,
1543
+ metadata,
1544
+ context,
1545
+ startTime,
1546
+ true,
1547
+ );
1548
+ }
1549
+ return;
754
1550
  }
755
1551
 
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
- );
1552
+ // No services available — use orchestrator if configured, else metadata only.
1553
+ if (config.orchestratorUrl && dataProductCoordinates) {
1554
+ completeThinkingSteps(setMessages);
1555
+ await processQuestionViaOrchestrator(
1556
+ question,
1557
+ dataProductCoordinates,
1558
+ metadata,
1559
+ context,
1560
+ pureExecutionContext,
1561
+ );
1562
+ } else {
1563
+ await handleMetadataQuestion(
1564
+ question,
1565
+ metadata,
1566
+ context,
1567
+ startTime,
1568
+ false,
1569
+ );
1570
+ }
772
1571
  } catch (error) {
773
1572
  assertErrorThrown(error);
774
1573
  addThinkingStep(
775
1574
  setMessages,
776
1575
  `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
777
1576
  );
778
- finishWithThinkingError(setMessages, error.message, startTime);
1577
+ finishWithThinkingError(
1578
+ setMessages,
1579
+ error.message,
1580
+ startTime,
1581
+ classifyError(error),
1582
+ );
779
1583
  }
780
1584
  }
781
1585
 
@@ -794,18 +1598,61 @@ export async function processQuestionWithIntent(
794
1598
 
795
1599
  if (intent === LegendAIQuestionIntent.METADATA) {
796
1600
  const startTime = Date.now();
797
- await handleMetadataQuestion(
798
- question,
799
- metadata,
800
- context,
801
- startTime,
802
- services.length > 0,
803
- );
1601
+ try {
1602
+ await handleMetadataQuestion(
1603
+ question,
1604
+ metadata,
1605
+ context,
1606
+ startTime,
1607
+ services.length > 0,
1608
+ );
1609
+ } catch (error) {
1610
+ assertErrorThrown(error);
1611
+ addThinkingStep(
1612
+ setMessages,
1613
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1614
+ );
1615
+ finishWithThinkingError(
1616
+ setMessages,
1617
+ error.message,
1618
+ startTime,
1619
+ classifyError(error),
1620
+ );
1621
+ }
804
1622
  return;
805
1623
  }
806
1624
 
807
1625
  if (intent === LegendAIQuestionIntent.ORCHESTRATOR) {
808
1626
  if (config.orchestratorUrl && dataProductCoordinates) {
1627
+ // When services are available, try SQL first even for ORCHESTRATOR intent
1628
+ if (services.length > 0) {
1629
+ const startTime = Date.now();
1630
+ try {
1631
+ addThinkingStep(setMessages, 'Preparing data query...');
1632
+ await processDataQuery(
1633
+ question,
1634
+ services,
1635
+ coordinates,
1636
+ metadata,
1637
+ context,
1638
+ startTime,
1639
+ orchestratorOptions,
1640
+ );
1641
+ } catch (error) {
1642
+ assertErrorThrown(error);
1643
+ addThinkingStep(
1644
+ setMessages,
1645
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1646
+ );
1647
+ finishWithThinkingError(
1648
+ setMessages,
1649
+ error.message,
1650
+ startTime,
1651
+ classifyError(error),
1652
+ );
1653
+ }
1654
+ return;
1655
+ }
809
1656
  await processQuestionViaOrchestrator(
810
1657
  question,
811
1658
  dataProductCoordinates,
@@ -836,7 +1683,12 @@ export async function processQuestionWithIntent(
836
1683
  setMessages,
837
1684
  `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
838
1685
  );
839
- finishWithThinkingError(setMessages, error.message, startTime);
1686
+ finishWithThinkingError(
1687
+ setMessages,
1688
+ error.message,
1689
+ startTime,
1690
+ classifyError(error),
1691
+ );
840
1692
  }
841
1693
  }
842
1694
 
@@ -989,6 +1841,64 @@ export const useLegendAIChatState = (
989
1841
  ],
990
1842
  );
991
1843
 
1844
+ const runFallbackAction = useCallback(
1845
+ (messageId: string): void => {
1846
+ if (isSending || !config.orchestratorUrl || !dataProductCoordinates) {
1847
+ return;
1848
+ }
1849
+ // Find the user question associated with this assistant message
1850
+ let question: string | undefined;
1851
+ for (let i = 0; i < messages.length; i++) {
1852
+ const msg = messages[i];
1853
+ if (
1854
+ msg?.role === LegendAIMessageRole.ASSISTANT &&
1855
+ msg.id === messageId &&
1856
+ i > 0
1857
+ ) {
1858
+ const userMsg = messages[i - 1];
1859
+ if (userMsg?.role === LegendAIMessageRole.USER) {
1860
+ question = userMsg.text;
1861
+ }
1862
+ }
1863
+ }
1864
+ if (!question) {
1865
+ return;
1866
+ }
1867
+
1868
+ setIsSending(true);
1869
+ setMessages((prev) =>
1870
+ prev.map((m) =>
1871
+ m.id === messageId && m.role === LegendAIMessageRole.ASSISTANT
1872
+ ? { ...m, fallbackAction: null, error: null, isProcessing: true }
1873
+ : m,
1874
+ ),
1875
+ );
1876
+
1877
+ const history = buildConversationHistory(messages);
1878
+ const q = question;
1879
+ processQuestionViaOrchestrator(
1880
+ q,
1881
+ dataProductCoordinates,
1882
+ metadata,
1883
+ { config, plugin, history, setMessages },
1884
+ pureExecutionContext,
1885
+ )
1886
+ .catch(noop())
1887
+ .finally(() => {
1888
+ setIsSending(false);
1889
+ });
1890
+ },
1891
+ [
1892
+ isSending,
1893
+ messages,
1894
+ config,
1895
+ metadata,
1896
+ plugin,
1897
+ dataProductCoordinates,
1898
+ pureExecutionContext,
1899
+ ],
1900
+ );
1901
+
992
1902
  return {
993
1903
  questionText,
994
1904
  setQuestionText,
@@ -996,6 +1906,7 @@ export const useLegendAIChatState = (
996
1906
  messages,
997
1907
  askQuestion,
998
1908
  askQuestionWithIntent,
1909
+ runFallbackAction,
999
1910
  clearChat,
1000
1911
  expandedThinking,
1001
1912
  toggleThinking,