@finos/legend-lego 2.0.202 → 2.0.204

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 (75) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAIDocEnrichment.d.ts +34 -1
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +890 -1
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts +36 -0
  8. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIServiceRetrieval.js +179 -0
  10. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -0
  11. package/lib/legend-ai/LegendAITypes.d.ts +62 -2
  12. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  13. package/lib/legend-ai/LegendAITypes.js +56 -30
  14. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  15. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +29 -6
  16. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  17. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +9 -0
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  19. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +5 -0
  20. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  21. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +18 -2
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  23. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
  24. package/lib/legend-ai/components/LegendAIAnalysisPanel.js +27 -8
  25. package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
  26. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +1 -0
  27. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
  28. package/lib/legend-ai/components/LegendAIAnalysisUtils.js +144 -22
  29. package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
  30. package/lib/legend-ai/components/LegendAICharts.d.ts +4 -0
  31. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  32. package/lib/legend-ai/components/LegendAICharts.js +60 -1
  33. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  34. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  35. package/lib/legend-ai/components/LegendAIChat.js +46 -17
  36. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  37. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +1 -1
  38. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
  39. package/lib/legend-ai/components/LegendAIChatHelpers.js +220 -15
  40. package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
  41. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  42. package/lib/legend-ai/components/LegendAIChatInput.js +1 -0
  43. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  44. package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -1
  45. package/lib/legend-ai/components/LegendAIScopeSelector.js +5 -2
  46. package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -1
  47. package/lib/legend-ai/index.d.ts +4 -3
  48. package/lib/legend-ai/index.d.ts.map +1 -1
  49. package/lib/legend-ai/index.js +4 -3
  50. package/lib/legend-ai/index.js.map +1 -1
  51. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +30 -13
  52. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  53. package/lib/legend-ai/stores/LegendAIChatProcessors.js +776 -216
  54. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  55. package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -2
  56. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  57. package/lib/legend-ai/stores/LegendAIChatState.js +26 -25
  58. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  59. package/package.json +5 -5
  60. package/src/legend-ai/LegendAIDocEnrichment.ts +1198 -1
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +228 -0
  62. package/src/legend-ai/LegendAITypes.ts +138 -32
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +62 -0
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +28 -0
  65. package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +36 -17
  66. package/src/legend-ai/components/LegendAIAnalysisUtils.ts +178 -22
  67. package/src/legend-ai/components/LegendAICharts.tsx +160 -0
  68. package/src/legend-ai/components/LegendAIChat.tsx +107 -14
  69. package/src/legend-ai/components/LegendAIChatHelpers.ts +334 -20
  70. package/src/legend-ai/components/LegendAIChatInput.tsx +2 -1
  71. package/src/legend-ai/components/LegendAIScopeSelector.tsx +7 -2
  72. package/src/legend-ai/index.ts +22 -1
  73. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1205 -321
  74. package/src/legend-ai/stores/LegendAIChatState.ts +80 -69
  75. package/tsconfig.json +1 -0
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type React from 'react';
18
- import { assertErrorThrown, uuid } from '@finos/legend-shared';
18
+ import { assertErrorThrown, isPlainObject, uuid } from '@finos/legend-shared';
19
19
  import {
20
20
  type TDSServiceSchema,
21
21
  type LegendAIConfig,
@@ -24,12 +24,13 @@ import {
24
24
  type LegendAIMessage,
25
25
  type LegendAIConversationTurn,
26
26
  type LegendAIProductMetadata,
27
- classifyQuestionIntentFast,
27
+ type LegendAIModelContext,
28
28
  LegendAIQuestionIntent,
29
29
  LegendAIThinkingStepStatus,
30
30
  LegendAIMessageRole,
31
31
  LegendAIErrorType,
32
32
  LegendAIServiceError,
33
+ LegendAIUnsupportedEngineShapeError,
33
34
  TDSServiceSourceType,
34
35
  buildColumnDefsFromNames,
35
36
  LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
@@ -46,20 +47,40 @@ import {
46
47
  type QueryExplicitExecutionContextInfo,
47
48
  extractElementNameFromPath,
48
49
  } from '@finos/legend-graph';
50
+ import {
51
+ buildEnrichedBusinessContext,
52
+ buildModelContextEnrichmentText,
53
+ findBestAlternateRoot,
54
+ splitIdentifierTokens,
55
+ tokenizeText,
56
+ } from '../LegendAIDocEnrichment.js';
57
+ import {
58
+ isFuzzyMatch,
59
+ preFilterServicesByRelevance,
60
+ } from '../LegendAIServiceRetrieval.js';
61
+ import {
62
+ isNumericColumn,
63
+ isStringColumn,
64
+ } from '../components/LegendAIChatHelpers.js';
49
65
 
50
66
  const MAX_ERROR_MESSAGE_LENGTH = 500;
51
67
  const MAX_THINKING_ERROR_PREVIEW_LENGTH = 200;
52
68
  const DEFAULT_MAX_JUDGE_ATTEMPTS = 5;
53
69
  const DEFAULT_MAX_EXECUTION_RETRIES = 3;
54
70
  const ANALYSIS_TIMEOUT_MS = 15_000;
71
+ const ORCHESTRATOR_GENERATION_TIMEOUT_MS = 120_000;
72
+ const EXECUTION_TIMEOUT_MS = 300_000;
55
73
  const ANALYSIS_PREVIEW_ROW_LIMIT = 3;
56
74
  const ANALYSIS_PREVIEW_VALUE_LIMIT = 40;
57
75
  const MAX_NON_SQL_PASS_ATTEMPTS = 2;
58
- const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
59
76
  const JOIN_PATTERN = /\bJOIN\b/i;
60
77
  const ORDER_BY_SPLIT = /\bORDER\s+BY\b/i;
61
78
  const UNION_ALL_PATTERN = /\bUNION\s+ALL\b/i;
62
79
  const LITERAL_COL_PATTERN = /,\s*'[^']*'\s+AS\s+(?:"[^"]+"|[a-z]\w*)/gi;
80
+ const SELECT_ALIAS_PATTERN =
81
+ /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
82
+ const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
83
+ const SERVICE_CALL_PATTERN = /\bservice\s*\([^()]*\)/gi;
63
84
  const DEFAULT_SAFETY_LIMIT = 1000;
64
85
  const HAS_LIMIT_PATTERN = /\bLIMIT\s+\d+/i;
65
86
  const HAS_AGGREGATION_PATTERN =
@@ -75,6 +96,7 @@ const SERVICE_PARAM_DATE_LIKE_PATTERNS: readonly RegExp[] = [
75
96
  /effective|valid|settle|trade|maturity|expir|inception|close|open/i,
76
97
  /start|end|from|until|begin|report|cutoff|valuation|pricing/i,
77
98
  ];
99
+ const GENERIC_TABLE_PATTERNS = /combined|consolidated|all|master|summary/i;
78
100
 
79
101
  function isLikelySqlQuery(text: string): boolean {
80
102
  const trimmed = text.trim().toLowerCase();
@@ -143,6 +165,7 @@ export function createMessagePair(
143
165
  isExecuting: false,
144
166
  suggestedQueries: [],
145
167
  fallbackAction: null,
168
+ queriedAccessPointGroups: [],
146
169
  },
147
170
  ];
148
171
  }
@@ -207,6 +230,64 @@ export function classifyError(error: Error): LegendAIErrorType {
207
230
  return LegendAIErrorType.GENERAL;
208
231
  }
209
232
 
233
+ export enum ExecutionErrorCategory {
234
+ INFRASTRUCTURE = 'INFRASTRUCTURE',
235
+ SQL_FIXABLE = 'SQL_FIXABLE',
236
+ ACCESS = 'ACCESS',
237
+ NONE = 'NONE',
238
+ }
239
+
240
+ const EXECUTION_ERROR_RULES: ReadonlyArray<{
241
+ category: ExecutionErrorCategory;
242
+ patterns: readonly RegExp[];
243
+ }> = [
244
+ {
245
+ category: ExecutionErrorCategory.ACCESS,
246
+ patterns: [
247
+ /\binsufficient privileges\b/i,
248
+ /\baccess denied\b/i,
249
+ /\bpermission denied\b/i,
250
+ /\bunauthorized\b/i,
251
+ /\bentitlement\b/i,
252
+ ],
253
+ },
254
+ {
255
+ category: ExecutionErrorCategory.INFRASTRUCTURE,
256
+ patterns: [/__lake_action/i, /__lake/i],
257
+ },
258
+ {
259
+ category: ExecutionErrorCategory.SQL_FIXABLE,
260
+ patterns: [
261
+ /\binvalid identifier\b/i,
262
+ /\bambiguous column\b/i,
263
+ /\bdoes not exist\b/i,
264
+ /\bsql compilation error\b/i,
265
+ /\bnot found\b/i,
266
+ ],
267
+ },
268
+ ];
269
+
270
+ export function categorizeExecutionError(
271
+ errMsg: string,
272
+ error?: Error,
273
+ ): ExecutionErrorCategory {
274
+ if (error instanceof LegendAIUnsupportedEngineShapeError) {
275
+ return ExecutionErrorCategory.SQL_FIXABLE;
276
+ }
277
+ if (
278
+ error instanceof LegendAIServiceError &&
279
+ error.errorType === LegendAIErrorType.PERMISSION
280
+ ) {
281
+ return ExecutionErrorCategory.ACCESS;
282
+ }
283
+ for (const rule of EXECUTION_ERROR_RULES) {
284
+ if (rule.patterns.some((pattern) => pattern.test(errMsg))) {
285
+ return rule.category;
286
+ }
287
+ }
288
+ return ExecutionErrorCategory.NONE;
289
+ }
290
+
210
291
  export function finishWithThinkingError(
211
292
  setMessages: MessageSetter,
212
293
  errorMsg: string,
@@ -236,7 +317,9 @@ function buildTurnFromAssistant(
236
317
  sql: asstMsg.sql,
237
318
  intent: LegendAIQuestionIntent.DATA_QUERY,
238
319
  };
239
- if (asstMsg.gridData) {
320
+ if (asstMsg.error) {
321
+ turn.resultSummary = `ERROR: ${asstMsg.error.slice(0, 200)}`;
322
+ } else if (asstMsg.gridData) {
240
323
  turn.rowCount = asstMsg.gridData.rowData.length;
241
324
  const colNames = asstMsg.gridData.columnDefs
242
325
  .map((c) => c.headerName ?? c.colId ?? '')
@@ -254,6 +337,14 @@ function buildTurnFromAssistant(
254
337
  intent: LegendAIQuestionIntent.METADATA,
255
338
  };
256
339
  }
340
+ if (asstMsg.error && !asstMsg.sql) {
341
+ return {
342
+ question: userText,
343
+ sql: '(generation failed)',
344
+ resultSummary: `ERROR: ${asstMsg.error.slice(0, 200)}`,
345
+ intent: LegendAIQuestionIntent.DATA_QUERY,
346
+ };
347
+ }
257
348
  return undefined;
258
349
  }
259
350
 
@@ -323,7 +414,21 @@ export function buildGenerationFailureMessage(
323
414
  }
324
415
 
325
416
  function buildFallbackSuggestions(services: TDSServiceSchema[]): string[] {
326
- return services.slice(0, 3).map((svc) => `Show 10 records from ${svc.title}`);
417
+ const result: string[] = [];
418
+ for (const svc of services.slice(0, 3)) {
419
+ const strCol = svc.columns.find(isStringColumn);
420
+ const numCol = svc.columns.find(isNumericColumn);
421
+ if (numCol && strCol) {
422
+ result.push(
423
+ `What are the top 10 ${strCol.name} values by ${numCol.name} in ${svc.title}?`,
424
+ );
425
+ } else if (strCol) {
426
+ result.push(`Show the breakdown by ${strCol.name} in ${svc.title}`);
427
+ } else {
428
+ result.push(`Show 10 records from ${svc.title}`);
429
+ }
430
+ }
431
+ return result;
327
432
  }
328
433
 
329
434
  function appendFallbackSuggestions(
@@ -338,6 +443,39 @@ function appendFallbackSuggestions(
338
443
  }
339
444
  }
340
445
 
446
+ function buildSnowflakeSqlError(
447
+ rawError: string,
448
+ services: TDSServiceSchema[],
449
+ ): string {
450
+ const lowerError = rawError.toLowerCase();
451
+ const coreMatch =
452
+ /SnowflakeSQLException:\s*(?<core>SQL compilation error[^"]*?)(?:\\n|\n|$)/i.exec(
453
+ rawError,
454
+ );
455
+ const coreMsg = coreMatch?.groups?.core?.trim() ?? 'SQL compilation error';
456
+ if (lowerError.includes('__lake_action') || lowerError.includes('__lake')) {
457
+ return [
458
+ `Lakehouse SQL execution failed: ${coreMsg}`,
459
+ '\nThis is an internal engine error with this access point — it may not be fully supported yet.',
460
+ '\nTry querying a different access point, or contact the data product team.',
461
+ ].join('');
462
+ }
463
+ if (
464
+ lowerError.includes('invalid identifier') ||
465
+ lowerError.includes('does not exist')
466
+ ) {
467
+ const svcCols = services.map(
468
+ (s) => `${s.title}: ${s.columns.map((c) => c.name).join(', ')}`,
469
+ );
470
+ return [
471
+ `Lakehouse SQL execution failed: ${coreMsg}`,
472
+ `\nAvailable columns:\n${svcCols.join('\n')}`,
473
+ '\nTry rephrasing your question so the AI can pick the correct columns.',
474
+ ].join('');
475
+ }
476
+ return `Lakehouse SQL execution failed: ${coreMsg}`;
477
+ }
478
+
341
479
  export function buildExecutionErrorMessage(
342
480
  errStr: string,
343
481
  services: TDSServiceSchema[],
@@ -367,7 +505,30 @@ export function buildExecutionErrorMessage(
367
505
  return errParts.join('');
368
506
  }
369
507
 
508
+ if (errLower.includes('rename(~')) {
509
+ return [
510
+ 'Cross-access-point JOINs on columns with the same name are not yet supported by the execution engine.',
511
+ '\nTry querying each access point separately, or ask a metadata question instead.',
512
+ ].join('');
513
+ }
514
+
515
+ if (
516
+ /can't find a match for function '\w+\(Timestamp/i.test(errStr) &&
517
+ errLower.includes('string')
518
+ ) {
519
+ return [
520
+ 'Date comparison type mismatch: a Timestamp column was compared with a plain string literal.',
521
+ '\nPlease rephrase your question so the AI can regenerate the query with the correct date format.',
522
+ ].join('');
523
+ }
524
+
525
+ // Snowflake SQL compilation errors — extract the core message from the trace
526
+ if (errLower.includes('snowflakesqlexception: sql compilation error')) {
527
+ return buildSnowflakeSqlError(errStr, services);
528
+ }
529
+
370
530
  errParts.push(errStr.slice(0, MAX_ERROR_MESSAGE_LENGTH));
531
+
371
532
  if (
372
533
  errLower.includes('column') &&
373
534
  (errLower.includes('not found') ||
@@ -408,11 +569,55 @@ function parseSuggestedQueries(rawAnswer: string): {
408
569
  return { answer, suggestedQueries };
409
570
  }
410
571
 
572
+ const COVERAGE_STOPWORDS = new Set([
573
+ 'show',
574
+ 'give',
575
+ 'join',
576
+ 'combine',
577
+ 'merge',
578
+ 'columns',
579
+ 'column',
580
+ 'results',
581
+ 'result',
582
+ 'data',
583
+ 'services',
584
+ 'service',
585
+ 'access',
586
+ 'points',
587
+ 'point',
588
+ 'table',
589
+ 'tables',
590
+ 'query',
591
+ 'rows',
592
+ 'records',
593
+ 'values',
594
+ 'list',
595
+ 'display',
596
+ 'fetch',
597
+ 'retrieve',
598
+ 'return',
599
+ 'from',
600
+ 'with',
601
+ 'that',
602
+ 'this',
603
+ 'what',
604
+ 'which',
605
+ 'where',
606
+ 'have',
607
+ 'each',
608
+ 'both',
609
+ 'first',
610
+ 'last',
611
+ 'some',
612
+ 'available',
613
+ 'possible',
614
+ ]);
615
+
411
616
  function tokenizeQuestionForCoverage(question: string): string[] {
412
- return question
413
- .toLowerCase()
414
- .split(/[^a-z0-9]+/)
415
- .filter((token) => token.length >= 4);
617
+ return tokenizeText(question, {
618
+ minLength: 4,
619
+ stopwords: COVERAGE_STOPWORDS,
620
+ });
416
621
  }
417
622
 
418
623
  function buildQuestionCoverageNote(
@@ -447,9 +652,51 @@ function formatPreviewValue(value: unknown): string {
447
652
  return `${raw.slice(0, ANALYSIS_PREVIEW_VALUE_LIMIT)}...`;
448
653
  }
449
654
 
655
+ function buildJoinDiagnosticNote(
656
+ query: string,
657
+ columns: string[],
658
+ rows: unknown[],
659
+ ): string {
660
+ if (!/\bJOIN\b/i.test(query) || rows.length === 0 || columns.length < 2) {
661
+ return '';
662
+ }
663
+
664
+ const allNullColumns: string[] = [];
665
+ const someDataColumns: string[] = [];
666
+ for (const col of columns) {
667
+ const allNull = rows.every((row) => {
668
+ if (isPlainObject(row)) {
669
+ const val = row[col];
670
+ return val === null || val === undefined;
671
+ }
672
+ return true;
673
+ });
674
+ if (allNull) {
675
+ allNullColumns.push(col);
676
+ } else {
677
+ someDataColumns.push(col);
678
+ }
679
+ }
680
+
681
+ if (
682
+ allNullColumns.length > 0 &&
683
+ someDataColumns.length > 0 &&
684
+ allNullColumns.length >= 2
685
+ ) {
686
+ return (
687
+ `Note: ${allNullColumns.length} columns (${allNullColumns.slice(0, 3).join(', ')}${allNullColumns.length > 3 ? ', ...' : ''}) ` +
688
+ 'returned entirely NULL values. This typically means the joined services do not share ' +
689
+ 'overlapping values for the join key. The services may track different records. ' +
690
+ 'Try querying each service separately to explore their data independently.'
691
+ );
692
+ }
693
+
694
+ return '';
695
+ }
696
+
450
697
  function buildDeterministicResultSummary(
451
698
  question: string,
452
- _query: string,
699
+ query: string,
453
700
  columns: string[],
454
701
  rows: unknown[],
455
702
  ): string {
@@ -458,11 +705,8 @@ function buildDeterministicResultSummary(
458
705
  const previewRows = rows
459
706
  .slice(0, ANALYSIS_PREVIEW_ROW_LIMIT)
460
707
  .map((row, index) => {
461
- if (row && typeof row === 'object' && !Array.isArray(row)) {
462
- const entries = Object.entries(row as Record<string, unknown>).slice(
463
- 0,
464
- 4,
465
- );
708
+ if (isPlainObject(row)) {
709
+ const entries = Object.entries(row).slice(0, 4);
466
710
  const formattedEntries = entries.map(
467
711
  ([key, value]) => `${key}: ${formatPreviewValue(value)}`,
468
712
  );
@@ -473,11 +717,15 @@ function buildDeterministicResultSummary(
473
717
  .join('\n');
474
718
 
475
719
  const coverageNote = buildQuestionCoverageNote(question, columns);
720
+ const joinDiagnostic = buildJoinDiagnosticNote(query, columns, rows);
476
721
  const parts = [
477
722
  `I retrieved ${rowCount} row${rowCount === 1 ? '' : 's'} for your question using this query.`,
478
723
  `Columns returned: ${selectedColumns}.`,
479
724
  `Sample rows:\n${previewRows || 'No sample rows available.'}`,
480
725
  ];
726
+ if (joinDiagnostic) {
727
+ parts.push(joinDiagnostic);
728
+ }
481
729
  if (coverageNote) {
482
730
  parts.push(coverageNote);
483
731
  }
@@ -536,6 +784,8 @@ export async function handleMetadataQuestion(
536
784
  context: LegendAIOperationContext,
537
785
  startTime: number,
538
786
  hasQueryableServices?: boolean,
787
+ services?: TDSServiceSchema[],
788
+ modelContextEnrichment?: string,
539
789
  ): Promise<void> {
540
790
  const { config, plugin, history, setMessages } = context;
541
791
  addThinkingStep(setMessages, 'Answering from product metadata...');
@@ -543,6 +793,8 @@ export async function handleMetadataQuestion(
543
793
  question,
544
794
  metadata,
545
795
  history,
796
+ services,
797
+ modelContextEnrichment,
546
798
  );
547
799
  const rawAnswer = await plugin.callLLM(metadataPromptText, config);
548
800
  const { answer, suggestedQueries: parsedSuggestions } =
@@ -668,6 +920,7 @@ export async function generateAndJudgeSql(
668
920
  context: LegendAIOperationContext,
669
921
  startTime: number,
670
922
  metadata?: LegendAIProductMetadata,
923
+ modelContextEnrichment?: string,
671
924
  ): Promise<string | null> {
672
925
  const { plugin, config, history, setMessages } = context;
673
926
  addThinkingStep(setMessages, 'Building context from service schemas...');
@@ -677,6 +930,7 @@ export async function generateAndJudgeSql(
677
930
  coordinates,
678
931
  history,
679
932
  metadata,
933
+ modelContextEnrichment,
680
934
  );
681
935
  addThinkingStep(setMessages, 'Generating SQL query...');
682
936
 
@@ -727,13 +981,16 @@ export async function generateAndJudgeAccessPointSql(
727
981
  accessPoints: TDSServiceSchema[],
728
982
  context: LegendAIOperationContext,
729
983
  startTime: number,
984
+ modelContextEnrichment?: string,
730
985
  ): Promise<string | null> {
731
986
  const { plugin, config, history, setMessages } = context;
732
987
  addThinkingStep(setMessages, 'Building context from access point schemas...');
988
+ plugin.preWarmSchemaAnalysis(accessPoints, config);
733
989
  const prompt = plugin.buildAccessPointGeneratorPrompt(
734
990
  question,
735
991
  accessPoints,
736
992
  history,
993
+ modelContextEnrichment,
737
994
  );
738
995
  addThinkingStep(setMessages, 'Generating SQL query for access points...');
739
996
 
@@ -769,7 +1026,13 @@ export async function generateAndJudgeAccessPointSql(
769
1026
  return runJudgeLoop(
770
1027
  generatedSql,
771
1028
  (sql) =>
772
- plugin.buildAccessPointJudgePrompt(sql, question, accessPoints, history),
1029
+ plugin.buildAccessPointJudgePrompt(
1030
+ sql,
1031
+ question,
1032
+ accessPoints,
1033
+ history,
1034
+ modelContextEnrichment,
1035
+ ),
773
1036
  context,
774
1037
  );
775
1038
  }
@@ -810,13 +1073,13 @@ export async function executeSqlAndReport(
810
1073
  ): Promise<LegendAISqlExecutionResultData | undefined> {
811
1074
  const execStartTime = Date.now();
812
1075
  try {
813
- const isAccessPoint = services.some(
814
- (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
1076
+ const rawResult = await executeSqlForServices(
1077
+ sql,
1078
+ services,
1079
+ dataProductCoordinates,
1080
+ plugin,
1081
+ config,
815
1082
  );
816
- const rawResult =
817
- isAccessPoint && dataProductCoordinates
818
- ? await plugin.executeLakehouseSql(sql, dataProductCoordinates, config)
819
- : await plugin.executeSql(sql, config);
820
1083
  return reportExecutionResult(
821
1084
  rawResult,
822
1085
  setMessages,
@@ -846,6 +1109,30 @@ export async function executeSqlAndReport(
846
1109
  }
847
1110
  }
848
1111
 
1112
+ const HAS_PURE_AGGREGATION_PATTERN =
1113
+ /->groupBy\(|->distinct\(|->count\(\)|->sum\(\)|->average\(\)|->olapGroupBy\(/;
1114
+
1115
+ const HAS_PURE_TAKE_PATTERN = /->take\(\s*\d+\s*\)/;
1116
+
1117
+ const PURE_LIMIT_PATTERN = /->limit\(\s*(?<count>\d+)\s*\)/g;
1118
+
1119
+ export function ensurePureSafetyLimit(
1120
+ pureQuery: string,
1121
+ limit: number = DEFAULT_SAFETY_LIMIT,
1122
+ ): string {
1123
+ const normalized = pureQuery.replaceAll(
1124
+ PURE_LIMIT_PATTERN,
1125
+ (_match, n) => `->take(${n})`,
1126
+ );
1127
+ if (HAS_PURE_TAKE_PATTERN.test(normalized)) {
1128
+ return normalized;
1129
+ }
1130
+ if (HAS_PURE_AGGREGATION_PATTERN.test(normalized)) {
1131
+ return normalized;
1132
+ }
1133
+ return `${normalized.trimEnd()}->take(${limit})`;
1134
+ }
1135
+
849
1136
  export async function executePureQueryAndReport(
850
1137
  pureQuery: string,
851
1138
  pureExecutionContext: QueryExplicitExecutionContextInfo,
@@ -854,16 +1141,25 @@ export async function executePureQueryAndReport(
854
1141
  plugin: LegendAI_LegendApplicationPlugin_Extension,
855
1142
  setMessages: MessageSetter,
856
1143
  startTime: number,
857
- ): Promise<LegendAISqlExecutionResultData> {
1144
+ ): Promise<LegendAISqlExecutionResultData | undefined> {
858
1145
  const execStartTime = Date.now();
1146
+ const safeQuery = ensurePureSafetyLimit(pureQuery);
859
1147
  try {
860
1148
  addThinkingStep(setMessages, 'Executing Pure query...');
861
- const rawResult = await plugin.executePureQuery(
862
- pureQuery,
863
- pureExecutionContext,
864
- dataProductCoordinates,
865
- config,
1149
+ const rawResult = await withTimeout(
1150
+ plugin.executePureQuery(
1151
+ safeQuery,
1152
+ pureExecutionContext,
1153
+ dataProductCoordinates,
1154
+ config,
1155
+ ),
1156
+ EXECUTION_TIMEOUT_MS,
866
1157
  );
1158
+ if (!rawResult) {
1159
+ throw new Error(
1160
+ 'Query execution timed out after 5 minutes. The dataset may be too large — try adding more filters or reducing the result set.',
1161
+ );
1162
+ }
867
1163
  return reportExecutionResult(
868
1164
  rawResult,
869
1165
  setMessages,
@@ -889,7 +1185,7 @@ export async function executePureQueryAndReport(
889
1185
  : execErrorType,
890
1186
  thinkingDuration: elapsedSeconds(startTime),
891
1187
  }));
892
- return { columns: [], rows: [] };
1188
+ return undefined;
893
1189
  }
894
1190
  }
895
1191
 
@@ -950,6 +1246,95 @@ export async function analyzeOrchestratorResults(
950
1246
  }
951
1247
  }
952
1248
 
1249
+ async function retryWithAlternateRoot(
1250
+ question: string,
1251
+ alternateRoot: string,
1252
+ resolvedEntities: LegendAIResolvedEntities,
1253
+ orchestratorOptions: Required<LegendAIOrchestratorOptionsParam>,
1254
+ metadata: LegendAIProductMetadata,
1255
+ context: LegendAIOperationContext,
1256
+ options: {
1257
+ startTime: number;
1258
+ modelContext?: LegendAIModelContext;
1259
+ },
1260
+ ): Promise<boolean> {
1261
+ const { startTime, modelContext } = options;
1262
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
1263
+ const { config, plugin, setMessages } = context;
1264
+ addThinkingStep(
1265
+ setMessages,
1266
+ `No results with ${extractElementNameFromPath(resolvedEntities.rootEntity)}, retrying with ${extractElementNameFromPath(alternateRoot)}...`,
1267
+ );
1268
+ try {
1269
+ const retryRelated = resolvedEntities.relatedEntities.filter(
1270
+ (e) => e !== alternateRoot,
1271
+ );
1272
+ const retryEnriched = modelContext
1273
+ ? buildEnrichedBusinessContext(
1274
+ question,
1275
+ alternateRoot,
1276
+ retryRelated,
1277
+ modelContext,
1278
+ )
1279
+ : undefined;
1280
+ const retryResponse = await withTimeout(
1281
+ plugin.generateQueryViaOrchestrator(
1282
+ {
1283
+ user_question: question,
1284
+ semantic_search_resolution_details: {
1285
+ data_product_coordinates: dataProductCoordinates,
1286
+ root_entity: alternateRoot,
1287
+ related_entities: retryRelated,
1288
+ ...(retryEnriched
1289
+ ? { enriched_business_context: retryEnriched }
1290
+ : {}),
1291
+ },
1292
+ },
1293
+ config,
1294
+ ),
1295
+ ORCHESTRATOR_GENERATION_TIMEOUT_MS,
1296
+ );
1297
+ if (!retryResponse) {
1298
+ addThinkingStep(setMessages, 'Retry query generation timed out');
1299
+ return false;
1300
+ }
1301
+ const retrySafeQuery = ensurePureSafetyLimit(retryResponse.legend_query);
1302
+ updateLastAssistant(setMessages, () => ({
1303
+ sql: retrySafeQuery,
1304
+ sqlGenTime: elapsedSeconds(startTime, 2),
1305
+ isExecuting: true,
1306
+ }));
1307
+ const retryResult = await executePureQueryAndReport(
1308
+ retrySafeQuery,
1309
+ pureExecutionContext,
1310
+ dataProductCoordinates,
1311
+ config,
1312
+ plugin,
1313
+ setMessages,
1314
+ startTime,
1315
+ );
1316
+ if (retryResult && retryResult.rows.length > 0) {
1317
+ await analyzeOrchestratorResults(
1318
+ question,
1319
+ retryResponse.legend_query,
1320
+ retryResult,
1321
+ metadata,
1322
+ context,
1323
+ startTime,
1324
+ );
1325
+ return true;
1326
+ }
1327
+ return false;
1328
+ } catch (retryError) {
1329
+ assertErrorThrown(retryError);
1330
+ addThinkingStep(
1331
+ setMessages,
1332
+ `Retry with alternate entity failed: ${retryError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1333
+ );
1334
+ return false;
1335
+ }
1336
+ }
1337
+
953
1338
  async function handleEmptyOrchestratorResults(
954
1339
  question: string,
955
1340
  legendQuery: string,
@@ -957,65 +1342,34 @@ async function handleEmptyOrchestratorResults(
957
1342
  metadata: LegendAIProductMetadata,
958
1343
  resolvedEntities: LegendAIResolvedEntities,
959
1344
  context: LegendAIOperationContext,
960
- startTime: number,
1345
+ options: {
1346
+ startTime: number;
1347
+ modelContext?: LegendAIModelContext;
1348
+ },
961
1349
  ): Promise<void> {
962
- const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
1350
+ const { startTime, modelContext } = options;
963
1351
  const { config, plugin, setMessages } = context;
964
1352
 
965
1353
  if (resolvedEntities.relatedEntities.length > 0) {
966
- const alternateRoot = resolvedEntities.relatedEntities[0];
1354
+ const alternateRoot = modelContext
1355
+ ? findBestAlternateRoot(
1356
+ resolvedEntities.rootEntity,
1357
+ resolvedEntities.relatedEntities,
1358
+ modelContext,
1359
+ )
1360
+ : resolvedEntities.relatedEntities[0];
967
1361
  if (alternateRoot) {
968
- addThinkingStep(
969
- setMessages,
970
- `No results with ${extractElementNameFromPath(resolvedEntities.rootEntity)}, retrying with ${extractElementNameFromPath(alternateRoot)}...`,
1362
+ const succeeded = await retryWithAlternateRoot(
1363
+ question,
1364
+ alternateRoot,
1365
+ resolvedEntities,
1366
+ orchestratorOptions,
1367
+ metadata,
1368
+ context,
1369
+ { startTime, ...(modelContext === undefined ? {} : { modelContext }) },
971
1370
  );
972
-
973
- try {
974
- const retryResponse = await plugin.generateQueryViaOrchestrator(
975
- {
976
- user_question: question,
977
- semantic_search_resolution_details: {
978
- data_product_coordinates: dataProductCoordinates,
979
- root_entity: alternateRoot,
980
- related_entities: resolvedEntities.relatedEntities.slice(1),
981
- },
982
- },
983
- config,
984
- );
985
-
986
- updateLastAssistant(setMessages, () => ({
987
- sql: retryResponse.legend_query,
988
- sqlGenTime: elapsedSeconds(startTime, 2),
989
- isExecuting: true,
990
- }));
991
-
992
- const retryResult = await executePureQueryAndReport(
993
- retryResponse.legend_query,
994
- pureExecutionContext,
995
- dataProductCoordinates,
996
- config,
997
- plugin,
998
- setMessages,
999
- startTime,
1000
- );
1001
-
1002
- if (retryResult.rows.length > 0) {
1003
- await analyzeOrchestratorResults(
1004
- question,
1005
- retryResponse.legend_query,
1006
- retryResult,
1007
- metadata,
1008
- context,
1009
- startTime,
1010
- );
1011
- return;
1012
- }
1013
- } catch (retryError) {
1014
- assertErrorThrown(retryError);
1015
- addThinkingStep(
1016
- setMessages,
1017
- `Retry with alternate entity failed: ${retryError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1018
- );
1371
+ if (succeeded) {
1372
+ return;
1019
1373
  }
1020
1374
  }
1021
1375
  }
@@ -1024,9 +1378,7 @@ async function handleEmptyOrchestratorResults(
1024
1378
  setMessages,
1025
1379
  'No results returned \u2014 building contextual guidance...',
1026
1380
  );
1027
- updateLastAssistant(setMessages, () => ({
1028
- isProcessing: true,
1029
- }));
1381
+ updateLastAssistant(setMessages, () => ({ isProcessing: true }));
1030
1382
  const fallback = await withTimeout(
1031
1383
  plugin.buildNoResultsFallback(question, legendQuery, metadata, config),
1032
1384
  ANALYSIS_TIMEOUT_MS,
@@ -1042,6 +1394,98 @@ async function handleEmptyOrchestratorResults(
1042
1394
  }
1043
1395
  }
1044
1396
 
1397
+ async function resolveOrchestrationEntities(
1398
+ question: string,
1399
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
1400
+ context: LegendAIOperationContext,
1401
+ pureExecutionContext?: QueryExplicitExecutionContextInfo,
1402
+ preResolvedEntities?: LegendAIResolvedEntities,
1403
+ modelContext?: LegendAIModelContext,
1404
+ ): Promise<LegendAIResolvedEntities> {
1405
+ const { config, plugin, setMessages } = context;
1406
+ if (preResolvedEntities) {
1407
+ addThinkingStep(
1408
+ setMessages,
1409
+ `Using pre-resolved root entity: ${extractElementNameFromPath(preResolvedEntities.rootEntity)}`,
1410
+ );
1411
+ return preResolvedEntities;
1412
+ }
1413
+ addThinkingStep(setMessages, 'Resolving entities for your query...');
1414
+ const resolvedEntities = await plugin.resolveEntitiesForQuery(
1415
+ question,
1416
+ dataProductCoordinates,
1417
+ config,
1418
+ pureExecutionContext,
1419
+ modelContext,
1420
+ );
1421
+ addThinkingStep(
1422
+ setMessages,
1423
+ `Found root entity: ${extractElementNameFromPath(resolvedEntities.rootEntity)}`,
1424
+ );
1425
+ return resolvedEntities;
1426
+ }
1427
+
1428
+ function extractUserErrorFromMessage(errorMessage: string): string {
1429
+ const marker = 'with error:';
1430
+ const idx = errorMessage.toLowerCase().indexOf(marker);
1431
+ if (idx !== -1) {
1432
+ const after = errorMessage.slice(idx + marker.length).trimStart();
1433
+ const newlineIdx = after.indexOf('\n');
1434
+ const reason = (
1435
+ newlineIdx === -1 ? after : after.slice(0, newlineIdx)
1436
+ ).trim();
1437
+ if (reason.length > 0) {
1438
+ return reason.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1439
+ }
1440
+ }
1441
+ return errorMessage.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1442
+ }
1443
+
1444
+ async function handleOrchestratorError(
1445
+ error: Error,
1446
+ startTime: number,
1447
+ question: string,
1448
+ metadata: LegendAIProductMetadata,
1449
+ context: LegendAIOperationContext,
1450
+ ): Promise<void> {
1451
+ const { config, plugin, setMessages } = context;
1452
+ const orchErrorType = classifyError(error);
1453
+ const genTime = elapsedSeconds(startTime, 2);
1454
+ addThinkingStep(
1455
+ setMessages,
1456
+ `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1457
+ );
1458
+ try {
1459
+ addThinkingStep(
1460
+ setMessages,
1461
+ 'Building guidance from available metadata...',
1462
+ );
1463
+ const fallbackText = await withTimeout(
1464
+ plugin.buildFailureFallback(question, error.message, metadata, config),
1465
+ ANALYSIS_TIMEOUT_MS,
1466
+ );
1467
+ if (fallbackText) {
1468
+ completeThinkingSteps(setMessages);
1469
+ updateLastAssistant(setMessages, () => ({
1470
+ dataContext: fallbackText,
1471
+ sqlGenTime: genTime,
1472
+ error: extractUserErrorFromMessage(error.message),
1473
+ errorType: LegendAIErrorType.GENERATION,
1474
+ isProcessing: false,
1475
+ thinkingDuration: elapsedSeconds(startTime),
1476
+ }));
1477
+ return;
1478
+ }
1479
+ } catch (fallbackError) {
1480
+ assertErrorThrown(fallbackError);
1481
+ addThinkingStep(
1482
+ setMessages,
1483
+ `Fallback guidance failed: ${fallbackError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1484
+ );
1485
+ }
1486
+ finishWithThinkingError(setMessages, error.message, startTime, orchErrorType);
1487
+ }
1488
+
1045
1489
  export async function processQuestionViaOrchestrator(
1046
1490
  question: string,
1047
1491
  dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
@@ -1049,31 +1493,20 @@ export async function processQuestionViaOrchestrator(
1049
1493
  context: LegendAIOperationContext,
1050
1494
  pureExecutionContext?: QueryExplicitExecutionContextInfo,
1051
1495
  preResolvedEntities?: LegendAIResolvedEntities,
1496
+ modelContext?: LegendAIModelContext,
1052
1497
  ): Promise<void> {
1053
1498
  const { config, plugin, setMessages } = context;
1054
1499
  const startTime = Date.now();
1055
1500
 
1056
1501
  try {
1057
- let resolvedEntities: LegendAIResolvedEntities;
1058
- if (preResolvedEntities) {
1059
- resolvedEntities = preResolvedEntities;
1060
- addThinkingStep(
1061
- setMessages,
1062
- `Using pre-resolved root entity: ${extractElementNameFromPath(resolvedEntities.rootEntity)}`,
1063
- );
1064
- } else {
1065
- addThinkingStep(setMessages, 'Resolving entities for your query...');
1066
- resolvedEntities = await plugin.resolveEntitiesForQuery(
1067
- question,
1068
- dataProductCoordinates,
1069
- config,
1070
- pureExecutionContext,
1071
- );
1072
- addThinkingStep(
1073
- setMessages,
1074
- `Found root entity: ${extractElementNameFromPath(resolvedEntities.rootEntity)}`,
1075
- );
1076
- }
1502
+ const resolvedEntities = await resolveOrchestrationEntities(
1503
+ question,
1504
+ dataProductCoordinates,
1505
+ context,
1506
+ pureExecutionContext,
1507
+ preResolvedEntities,
1508
+ modelContext,
1509
+ );
1077
1510
 
1078
1511
  if (resolvedEntities.relatedEntities.length > 0) {
1079
1512
  addThinkingStep(
@@ -1083,23 +1516,43 @@ export async function processQuestionViaOrchestrator(
1083
1516
  }
1084
1517
 
1085
1518
  addThinkingStep(setMessages, 'Generating Legend query via orchestrator...');
1086
- const orchestratorResponse = await plugin.generateQueryViaOrchestrator(
1087
- {
1088
- user_question: question,
1089
- semantic_search_resolution_details: {
1090
- data_product_coordinates: dataProductCoordinates,
1091
- root_entity: resolvedEntities.rootEntity,
1092
- related_entities: resolvedEntities.relatedEntities,
1519
+ const enrichedContext = modelContext
1520
+ ? buildEnrichedBusinessContext(
1521
+ question,
1522
+ resolvedEntities.rootEntity,
1523
+ resolvedEntities.relatedEntities,
1524
+ modelContext,
1525
+ )
1526
+ : undefined;
1527
+ const orchestratorResponse = await withTimeout(
1528
+ plugin.generateQueryViaOrchestrator(
1529
+ {
1530
+ user_question: question,
1531
+ semantic_search_resolution_details: {
1532
+ data_product_coordinates: dataProductCoordinates,
1533
+ root_entity: resolvedEntities.rootEntity,
1534
+ related_entities: resolvedEntities.relatedEntities,
1535
+ ...(enrichedContext
1536
+ ? { enriched_business_context: enrichedContext }
1537
+ : {}),
1538
+ },
1093
1539
  },
1094
- },
1095
- config,
1540
+ config,
1541
+ ),
1542
+ ORCHESTRATOR_GENERATION_TIMEOUT_MS,
1096
1543
  );
1097
1544
 
1098
- const queryGenTime = elapsedSeconds(startTime, 2);
1545
+ if (!orchestratorResponse) {
1546
+ throw new Error(
1547
+ 'Query generation timed out. The orchestrator took too long to respond. Try a simpler question.',
1548
+ );
1549
+ }
1550
+
1551
+ const safeQuery = ensurePureSafetyLimit(orchestratorResponse.legend_query);
1099
1552
  completeThinkingSteps(setMessages);
1100
1553
  updateLastAssistant(setMessages, () => ({
1101
- sql: orchestratorResponse.legend_query,
1102
- sqlGenTime: queryGenTime,
1554
+ sql: safeQuery,
1555
+ sqlGenTime: elapsedSeconds(startTime, 2),
1103
1556
  isExecuting: true,
1104
1557
  isProcessing: true,
1105
1558
  }));
@@ -1117,7 +1570,7 @@ export async function processQuestionViaOrchestrator(
1117
1570
  }
1118
1571
 
1119
1572
  const execResult = await executePureQueryAndReport(
1120
- orchestratorResponse.legend_query,
1573
+ safeQuery,
1121
1574
  pureExecutionContext,
1122
1575
  dataProductCoordinates,
1123
1576
  config,
@@ -1126,11 +1579,15 @@ export async function processQuestionViaOrchestrator(
1126
1579
  startTime,
1127
1580
  );
1128
1581
 
1582
+ if (!execResult) {
1583
+ return;
1584
+ }
1585
+
1129
1586
  try {
1130
1587
  if (execResult.rows.length > 0) {
1131
1588
  await analyzeOrchestratorResults(
1132
1589
  question,
1133
- orchestratorResponse.legend_query,
1590
+ safeQuery,
1134
1591
  execResult,
1135
1592
  metadata,
1136
1593
  context,
@@ -1139,12 +1596,15 @@ export async function processQuestionViaOrchestrator(
1139
1596
  } else {
1140
1597
  await handleEmptyOrchestratorResults(
1141
1598
  question,
1142
- orchestratorResponse.legend_query,
1599
+ safeQuery,
1143
1600
  { dataProductCoordinates, pureExecutionContext },
1144
1601
  metadata,
1145
1602
  resolvedEntities,
1146
1603
  context,
1147
- startTime,
1604
+ {
1605
+ startTime,
1606
+ ...(modelContext === undefined ? {} : { modelContext }),
1607
+ },
1148
1608
  );
1149
1609
  }
1150
1610
  } catch (analysisError) {
@@ -1162,43 +1622,12 @@ export async function processQuestionViaOrchestrator(
1162
1622
  }
1163
1623
  } catch (error) {
1164
1624
  assertErrorThrown(error);
1165
- const orchErrorType = classifyError(error);
1166
- addThinkingStep(
1167
- setMessages,
1168
- `Error: ${error.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1169
- );
1170
-
1171
- try {
1172
- addThinkingStep(
1173
- setMessages,
1174
- 'Building guidance from available metadata...',
1175
- );
1176
- const fallbackText = await withTimeout(
1177
- plugin.buildFailureFallback(question, error.message, metadata, config),
1178
- ANALYSIS_TIMEOUT_MS,
1179
- );
1180
- if (fallbackText) {
1181
- completeThinkingSteps(setMessages);
1182
- updateLastAssistant(setMessages, () => ({
1183
- dataContext: fallbackText,
1184
- isProcessing: false,
1185
- thinkingDuration: elapsedSeconds(startTime),
1186
- }));
1187
- return;
1188
- }
1189
- } catch (fallbackError) {
1190
- assertErrorThrown(fallbackError);
1191
- addThinkingStep(
1192
- setMessages,
1193
- `Fallback guidance failed: ${fallbackError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1194
- );
1195
- }
1196
-
1197
- finishWithThinkingError(
1198
- setMessages,
1199
- error.message,
1625
+ await handleOrchestratorError(
1626
+ error,
1200
1627
  startTime,
1201
- orchErrorType,
1628
+ question,
1629
+ metadata,
1630
+ context,
1202
1631
  );
1203
1632
  }
1204
1633
  }
@@ -1236,10 +1665,7 @@ export function sanitizeJoinOrderBy(sql: string): string {
1236
1665
  const afterOrderBy = parts.slice(1).join('ORDER BY').replace(/^\s+/, '');
1237
1666
 
1238
1667
  const selectAliases = new Map<string, string>();
1239
- const aliasRegex =
1240
- /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
1241
- let m: RegExpExecArray | null;
1242
- while ((m = aliasRegex.exec(beforeOrderBy)) !== null) {
1668
+ for (const m of beforeOrderBy.matchAll(SELECT_ALIAS_PATTERN)) {
1243
1669
  const tableAlias = (m.groups?.tbl ?? '').toLowerCase();
1244
1670
  const colName = (m.groups?.col ?? '').toLowerCase();
1245
1671
  const asAlias = m.groups?.qAlias ?? m.groups?.uAlias ?? '';
@@ -1250,17 +1676,25 @@ export function sanitizeJoinOrderBy(sql: string): string {
1250
1676
  return sql;
1251
1677
  }
1252
1678
 
1679
+ // Build a lookup of the original alias.col text → its SELECT alias replacement.
1680
+ // matchAll exposes typed `.groups`, so no cast is needed.
1681
+ const replacements = new Map<string, string>();
1682
+ for (const m of afterOrderBy.matchAll(ALIAS_DOT_COL_PATTERN)) {
1683
+ const tbl = (m.groups?.tbl ?? '').toLowerCase();
1684
+ const col = (m.groups?.col ?? '').toLowerCase();
1685
+ const alias = selectAliases.get(`${tbl}.${col}`);
1686
+ if (alias) {
1687
+ replacements.set(m[0], `"${alias}"`);
1688
+ }
1689
+ }
1690
+
1691
+ if (replacements.size === 0) {
1692
+ return sql;
1693
+ }
1694
+
1253
1695
  const rewritten = afterOrderBy.replaceAll(
1254
1696
  ALIAS_DOT_COL_PATTERN,
1255
- (...args) => {
1256
- const groups = args[args.length - 1] as {
1257
- tbl: string;
1258
- col: string;
1259
- };
1260
- const key = `${groups.tbl.toLowerCase()}.${groups.col.toLowerCase()}`;
1261
- const alias = selectAliases.get(key);
1262
- return alias ? `"${alias}"` : String(args[0]);
1263
- },
1697
+ (match) => replacements.get(match) ?? match,
1264
1698
  );
1265
1699
 
1266
1700
  if (rewritten === afterOrderBy) {
@@ -1269,6 +1703,88 @@ export function sanitizeJoinOrderBy(sql: string): string {
1269
1703
  return `${beforeOrderBy}ORDER BY ${rewritten}`;
1270
1704
  }
1271
1705
 
1706
+ export function sanitizeJoinSameKeyColumns(
1707
+ sql: string,
1708
+ services?: TDSServiceSchema[],
1709
+ ): string {
1710
+ if (!JOIN_PATTERN.test(sql)) {
1711
+ return sql;
1712
+ }
1713
+
1714
+ const joinRegex =
1715
+ /\bJOIN\s{1,5}p\(\s{0,5}'(?<pId>[^']{1,200})'\s{0,5}\)\s{1,5}AS\s{1,5}(?<rAlias>[a-z]\w{0,63})\s{1,5}ON\s{1,5}(?<onClause>[^\n]{1,500})/gi;
1716
+
1717
+ let result = sql;
1718
+ let match: RegExpExecArray | null;
1719
+
1720
+ while ((match = joinRegex.exec(sql)) !== null) {
1721
+ const pId = match.groups?.pId ?? '';
1722
+ const rightAlias = match.groups?.rAlias ?? '';
1723
+ const onClause = (match.groups?.onClause ?? '').slice(0, 500);
1724
+
1725
+ const sameKeyMatch =
1726
+ /(?<lAlias>[a-z]\w{0,63}) {0,5}\. {0,5}"(?<lCol>[^"]{1,200})" {0,5}= {0,5}(?<rAl>[a-z]\w{0,63}) {0,5}\. {0,5}"(?<rCol>[^"]{1,200})"/i.exec(
1727
+ onClause,
1728
+ );
1729
+ if (!sameKeyMatch) {
1730
+ continue;
1731
+ }
1732
+
1733
+ const leftCol = sameKeyMatch.groups?.lCol ?? '';
1734
+ const rightCol = sameKeyMatch.groups?.rCol ?? '';
1735
+ const matchedRightAlias = sameKeyMatch.groups?.rAl ?? '';
1736
+
1737
+ if (
1738
+ leftCol.toLowerCase() !== rightCol.toLowerCase() ||
1739
+ matchedRightAlias.toLowerCase() !== rightAlias.toLowerCase()
1740
+ ) {
1741
+ continue;
1742
+ }
1743
+
1744
+ const renamedKey = `${rightAlias}_${rightCol}`;
1745
+ const columnList = buildSubqueryColumnList(
1746
+ pId,
1747
+ rightCol,
1748
+ renamedKey,
1749
+ services,
1750
+ );
1751
+ const originalFragment = `p('${pId}') AS ${rightAlias}`;
1752
+ const subqueryFragment = `(SELECT ${columnList} FROM p('${pId}')) AS ${rightAlias}`;
1753
+
1754
+ const oldOnRef = `${rightAlias}."${rightCol}"`;
1755
+ const newOnRef = `${rightAlias}."${renamedKey}"`;
1756
+
1757
+ result = result.replace(originalFragment, subqueryFragment);
1758
+ result = result.replaceAll(oldOnRef, newOnRef);
1759
+ }
1760
+
1761
+ return result;
1762
+ }
1763
+
1764
+ function buildSubqueryColumnList(
1765
+ pId: string,
1766
+ originalKey: string,
1767
+ renamedKey: string,
1768
+ services?: TDSServiceSchema[],
1769
+ ): string {
1770
+ const svc = services?.find(
1771
+ (s) =>
1772
+ s.dataProductPath &&
1773
+ s.pattern &&
1774
+ pId === `${s.dataProductPath}.${s.pattern.replace(/^\//, '')}`,
1775
+ );
1776
+ if (!svc || svc.columns.length === 0) {
1777
+ return `"${originalKey}" AS "${renamedKey}", *`;
1778
+ }
1779
+ return svc.columns
1780
+ .map((c) =>
1781
+ c.name.toLowerCase() === originalKey.toLowerCase()
1782
+ ? `"${c.name}" AS "${renamedKey}"`
1783
+ : `"${c.name}"`,
1784
+ )
1785
+ .join(', ');
1786
+ }
1787
+
1272
1788
  export function sanitizeLiteralColumns(sql: string): string {
1273
1789
  if (!UNION_ALL_PATTERN.test(sql)) {
1274
1790
  return sql;
@@ -1347,8 +1863,7 @@ export function ensureDateParameters(
1347
1863
 
1348
1864
  const today = getTodayISO();
1349
1865
 
1350
- const serviceCallPattern = /\bservice\s*\([^()]*\)/gi;
1351
- return sql.replaceAll(serviceCallPattern, (match) => {
1866
+ return sql.replaceAll(SERVICE_CALL_PATTERN, (match) => {
1352
1867
  let patched = match;
1353
1868
  for (const param of dateParams) {
1354
1869
  if (new RegExp(String.raw`\b${param}\s*=>`, 'i').test(patched)) {
@@ -1450,6 +1965,111 @@ export function buildMissingParamsWarning(
1450
1965
  ].join('\n');
1451
1966
  }
1452
1967
 
1968
+ const NESTED_P_IN_CLAUSE = /\bIN\s*\(\s*SELECT\b[^)]*\bFROM\s+p\s*\(/i;
1969
+ const NESTED_P_CROSS_JOIN =
1970
+ /\bCROSS\s+JOIN\s*\(\s*SELECT\b[^)]*\bFROM\s+p\s*\(/i;
1971
+ const NESTED_P_SCALAR =
1972
+ /\(\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX)\s*\([^)]*\)\s+(?:AS\s+\w+\s+)?FROM\s+p\s*\(/i;
1973
+
1974
+ export function hasNestedPCalls(sql: string): boolean {
1975
+ return (
1976
+ NESTED_P_IN_CLAUSE.test(sql) ||
1977
+ NESTED_P_CROSS_JOIN.test(sql) ||
1978
+ NESTED_P_SCALAR.test(sql)
1979
+ );
1980
+ }
1981
+
1982
+ const NESTED_AGGREGATE_PATTERN =
1983
+ /\b(?:SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?:SUM|AVG|MIN|MAX|COUNT)\s*\(/i;
1984
+ const OVER_CLAUSE_PATTERN = /\bOVER\s*\(/i;
1985
+
1986
+ const AGGREGATE_IN_WINDOW_ARGS_PATTERN =
1987
+ /\bOVER\s*\((?:[^()]|\([^()]*\)){0,300}\b(?:SUM|AVG|MIN|MAX|COUNT)\s*\(/i;
1988
+
1989
+ const NON_WINDOW_SCALAR_FUNCTIONS: ReadonlySet<string> = new Set([
1990
+ 'ROUND',
1991
+ 'CAST',
1992
+ 'COALESCE',
1993
+ 'CEIL',
1994
+ 'CEILING',
1995
+ 'FLOOR',
1996
+ 'ABS',
1997
+ 'CONCAT',
1998
+ 'NULLIF',
1999
+ 'IFNULL',
2000
+ 'GREATEST',
2001
+ 'LEAST',
2002
+ 'TRUNC',
2003
+ 'TRUNCATE',
2004
+ 'MOD',
2005
+ 'POWER',
2006
+ 'SQRT',
2007
+ 'LN',
2008
+ 'LOG',
2009
+ 'EXP',
2010
+ 'SUBSTR',
2011
+ 'SUBSTRING',
2012
+ 'TO_CHAR',
2013
+ 'TO_NUMBER',
2014
+ 'TO_DATE',
2015
+ ]);
2016
+ const FUNCTION_CALL_WITH_OVER_PATTERN =
2017
+ /\b(?<fn>[A-Z_]{1,32})\s*\((?:[^()]|\([^()]*\)){0,200}\bOVER\s*\(/gi;
2018
+
2019
+ function hasWindowInsideNonWindowFunctionCall(sql: string): boolean {
2020
+ FUNCTION_CALL_WITH_OVER_PATTERN.lastIndex = 0;
2021
+ let match: RegExpExecArray | null;
2022
+ while ((match = FUNCTION_CALL_WITH_OVER_PATTERN.exec(sql)) !== null) {
2023
+ const fn = match.groups?.fn?.toUpperCase();
2024
+ if (fn && NON_WINDOW_SCALAR_FUNCTIONS.has(fn)) {
2025
+ return true;
2026
+ }
2027
+ }
2028
+ return false;
2029
+ }
2030
+
2031
+ const LATERAL_SUBQUERY_PATTERN =
2032
+ /\b(?:CROSS|INNER|LEFT|RIGHT)?\s*JOIN\s+LATERAL\b|\bLATERAL\s*\(/i;
2033
+
2034
+ export interface UnsupportedEnginePattern {
2035
+ kind:
2036
+ | 'NESTED_AGGREGATE_IN_WINDOW'
2037
+ | 'AGGREGATE_IN_WINDOW_ARGS'
2038
+ | 'WINDOW_INSIDE_FUNCTION_CALL'
2039
+ | 'LATERAL_SUBQUERY';
2040
+ hint: string;
2041
+ }
2042
+
2043
+ export function detectUnsupportedEnginePattern(
2044
+ sql: string,
2045
+ ): UnsupportedEnginePattern | undefined {
2046
+ if (NESTED_AGGREGATE_PATTERN.test(sql) && OVER_CLAUSE_PATTERN.test(sql)) {
2047
+ return {
2048
+ kind: 'NESTED_AGGREGATE_IN_WINDOW',
2049
+ hint: "The engine's SQL→Pure translator cannot combine an aggregate function with a window function in the same SELECT (e.g. SUM(COUNT(*)) OVER ()). Move the aggregation into a CTE first, then apply the window over the CTE.",
2050
+ };
2051
+ }
2052
+ if (AGGREGATE_IN_WINDOW_ARGS_PATTERN.test(sql)) {
2053
+ return {
2054
+ kind: 'AGGREGATE_IN_WINDOW_ARGS',
2055
+ hint: "The engine's SQL→Pure translator cannot evaluate an aggregate function inside an OVER clause's PARTITION BY or ORDER BY at the same level as GROUP BY (e.g. ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC)). Materialize the aggregate into a CTE column first, then reference that column from the window — for example: SELECT col, cnt, ROW_NUMBER() OVER (ORDER BY cnt DESC) AS rn FROM (SELECT col, COUNT(*) AS cnt FROM p('…') GROUP BY col) agg.",
2056
+ };
2057
+ }
2058
+ if (hasWindowInsideNonWindowFunctionCall(sql)) {
2059
+ return {
2060
+ kind: 'WINDOW_INSIDE_FUNCTION_CALL',
2061
+ hint: "The engine's SQL→Pure translator cannot evaluate a window function inside another function call (e.g. ROUND(SUM(x) OVER (), 2)). Materialize the window expression into its own column in a subquery or CTE, then apply the wrapping function in the outer SELECT — for example: SELECT col, ROUND(cnt * 100.0 / total, 2) FROM (SELECT col, cnt, SUM(cnt) OVER () AS total FROM (SELECT col, COUNT(*) AS cnt FROM p('…') GROUP BY col) agg) windowed.",
2062
+ };
2063
+ }
2064
+ if (LATERAL_SUBQUERY_PATTERN.test(sql)) {
2065
+ return {
2066
+ kind: 'LATERAL_SUBQUERY',
2067
+ hint: "The engine's SQL parser does not support LATERAL subqueries (CROSS JOIN LATERAL (...)). Rewrite using ROW_NUMBER() OVER (PARTITION BY ...) in a CTE and filter on the rank, or use a correlated subquery.",
2068
+ };
2069
+ }
2070
+ return undefined;
2071
+ }
2072
+
1453
2073
  /**
1454
2074
  * Appends a safety LIMIT to queries that lack one, preventing unbounded
1455
2075
  * result sets on large services. Skips aggregation queries since those
@@ -1465,6 +2085,29 @@ export function ensureSafeLimit(
1465
2085
  return `${sql.trimEnd()}\nLIMIT ${limit}`;
1466
2086
  }
1467
2087
 
2088
+ function prepareSafeSql(sql: string, services: TDSServiceSchema[]): string {
2089
+ const safeSql = ensureSafeLimit(
2090
+ ensureDateParameters(
2091
+ sanitizeLiteralColumns(
2092
+ sanitizeJoinSameKeyColumns(sanitizeJoinOrderBy(sql), services),
2093
+ ),
2094
+ services,
2095
+ ),
2096
+ );
2097
+ const unsupported = detectUnsupportedEnginePattern(safeSql);
2098
+ if (unsupported) {
2099
+ throw new LegendAIUnsupportedEngineShapeError(unsupported.hint);
2100
+ }
2101
+ return safeSql;
2102
+ }
2103
+
2104
+ /**
2105
+ * The single execution chokepoint for SQL in this module. Every code
2106
+ * path that needs to run SQL — primary orchestrator, retry-with-fix,
2107
+ * exported `executeSqlAndReport`, internal helpers — calls through
2108
+ * here. No other code in this file may call `plugin.executeLakehouseSql`
2109
+ * or `plugin.executeSql` directly.
2110
+ */
1468
2111
  async function executeSqlForServices(
1469
2112
  sql: string,
1470
2113
  services: TDSServiceSchema[],
@@ -1474,12 +2117,7 @@ async function executeSqlForServices(
1474
2117
  plugin: LegendAI_LegendApplicationPlugin_Extension,
1475
2118
  config: LegendAIConfig,
1476
2119
  ): Promise<LegendAISqlExecutionResultData> {
1477
- const safeSql = ensureSafeLimit(
1478
- ensureDateParameters(
1479
- sanitizeLiteralColumns(sanitizeJoinOrderBy(sql)),
1480
- services,
1481
- ),
1482
- );
2120
+ const safeSql = prepareSafeSql(sql, services);
1483
2121
  const isAccessPoint = services.some(
1484
2122
  (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
1485
2123
  );
@@ -1808,51 +2446,150 @@ async function reportQueryResults(
1808
2446
  }
1809
2447
  }
1810
2448
 
1811
- /**
1812
- * Scores each service against the user question by counting keyword
1813
- * overlap between the question tokens and the service title, description,
1814
- * column names, and parameter names. Returns services sorted by
1815
- * descending relevance score.
1816
- */
1817
- export function preFilterServicesByRelevance(
2449
+ export function supplementMissingCoverage(
1818
2450
  question: string,
1819
- services: TDSServiceSchema[],
1820
- limit: number,
2451
+ selected: TDSServiceSchema[],
2452
+ allServices: TDSServiceSchema[],
2453
+ maxTotal: number = 4,
1821
2454
  ): TDSServiceSchema[] {
1822
- const queryTokens = question
1823
- .toLowerCase()
1824
- .split(/[\s,.:;!?'"()\-/]+/)
1825
- .filter((t) => t.length > 2);
1826
- if (queryTokens.length === 0) {
1827
- return services.slice(0, limit);
1828
- }
1829
- const scored = services.map((svc) => {
1830
- const haystack = [
1831
- svc.title,
1832
- svc.description ?? '',
1833
- svc.pattern,
1834
- ...svc.columns.slice(0, 30).map((c) => c.name),
1835
- ...svc.parameters,
1836
- ...(svc.preFilters ?? []).flatMap((pf) => {
1837
- const parts = [pf.property, pf.operator];
1838
- if (pf.value !== undefined) {
1839
- parts.push(String(pf.value));
2455
+ if (selected.length >= maxTotal || allServices.length <= selected.length) {
2456
+ return selected;
2457
+ }
2458
+ const questionTokens = tokenizeText(question);
2459
+ if (questionTokens.length === 0) {
2460
+ return selected;
2461
+ }
2462
+
2463
+ const coveredTokens = new Set<string>();
2464
+ for (const svc of selected) {
2465
+ for (const col of svc.columns) {
2466
+ for (const token of splitIdentifierTokens(col.name)) {
2467
+ coveredTokens.add(token);
2468
+ }
2469
+ coveredTokens.add(col.name.toLowerCase());
2470
+ }
2471
+ for (const token of splitIdentifierTokens(svc.title)) {
2472
+ coveredTokens.add(token);
2473
+ }
2474
+ }
2475
+
2476
+ const coveredArray = Array.from(coveredTokens);
2477
+ const uncovered = questionTokens.filter(
2478
+ (t) =>
2479
+ !coveredTokens.has(t) &&
2480
+ !(t.length >= 4 && coveredArray.some((ct) => isFuzzyMatch(t, ct))),
2481
+ );
2482
+ if (uncovered.length === 0) {
2483
+ return selected;
2484
+ }
2485
+
2486
+ const selectedSet = new Set(selected.map((s) => s.pattern));
2487
+ const supplementCandidates = allServices
2488
+ .filter((svc) => !selectedSet.has(svc.pattern))
2489
+ .map((svc) => {
2490
+ const svcTokens = new Set<string>();
2491
+ for (const col of svc.columns) {
2492
+ svcTokens.add(col.name.toLowerCase());
2493
+ for (const token of splitIdentifierTokens(col.name)) {
2494
+ svcTokens.add(token);
1840
2495
  }
1841
- return parts;
1842
- }),
1843
- ]
1844
- .join(' ')
1845
- .toLowerCase();
1846
- let score = 0;
1847
- for (const token of queryTokens) {
1848
- if (haystack.includes(token)) {
1849
- score += 1;
1850
2496
  }
2497
+ for (const token of splitIdentifierTokens(svc.title)) {
2498
+ svcTokens.add(token);
2499
+ }
2500
+ const coverCount = uncovered.filter((t) => svcTokens.has(t)).length;
2501
+ const isGeneric = GENERIC_TABLE_PATTERNS.test(svc.title) ? 1 : 0;
2502
+ return { svc, coverCount, isGeneric };
2503
+ })
2504
+ .filter((c) => c.coverCount > 0)
2505
+ .sort((a, b) => b.coverCount - a.coverCount || a.isGeneric - b.isGeneric);
2506
+
2507
+ const result = [...selected];
2508
+ for (const candidate of supplementCandidates) {
2509
+ if (result.length >= maxTotal) {
2510
+ break;
1851
2511
  }
1852
- return { svc, score };
2512
+ result.push(candidate.svc);
2513
+ }
2514
+ return result;
2515
+ }
2516
+
2517
+ // ─── Question Normalization ──────────────────────────────────────────────────
2518
+
2519
+ /** Delegates to the plugin's LLM to fix natural-language typos in the user's question before AP selection; falls back to the original on failure. */
2520
+ export async function normalizeQuestion(
2521
+ question: string,
2522
+ plugin: LegendAI_LegendApplicationPlugin_Extension,
2523
+ config: LegendAIConfig,
2524
+ ): Promise<string> {
2525
+ try {
2526
+ return await plugin.normalizeQuestion(question, config);
2527
+ } catch (error) {
2528
+ assertErrorThrown(error);
2529
+ return question;
2530
+ }
2531
+ }
2532
+
2533
+ // ─── Multi-Turn AP Bias ──────────────────────────────────────────────────────
2534
+
2535
+ /**
2536
+ * Extracts AP patterns that were successfully used in previous conversation
2537
+ * turns. When the user asks a follow-up question, these APs should be
2538
+ * preferred since they're likely still relevant.
2539
+ */
2540
+ function extractPreviousTurnAPPatterns(
2541
+ history: LegendAIConversationTurn[],
2542
+ ): Set<string> {
2543
+ const patterns = new Set<string>();
2544
+ for (const turn of history) {
2545
+ const pCallPattern = /p\(\s*'(?<pId>[^']+)'\s*\)/g;
2546
+ let pCall: RegExpExecArray | null;
2547
+ while ((pCall = pCallPattern.exec(turn.sql)) !== null) {
2548
+ const pattern = pCall.groups?.pId;
2549
+ if (pattern) {
2550
+ const lastDot = pattern.lastIndexOf('.');
2551
+ if (lastDot >= 0) {
2552
+ patterns.add(pattern.slice(lastDot + 1).toLowerCase());
2553
+ }
2554
+ patterns.add(pattern.toLowerCase());
2555
+ }
2556
+ }
2557
+ }
2558
+ return patterns;
2559
+ }
2560
+
2561
+ /**
2562
+ * Applies a scoring boost to services whose patterns appeared in previous
2563
+ * conversation turns, biasing the pre-filter toward continuity when the
2564
+ * user asks follow-up questions about the same data.
2565
+ */
2566
+ export function applyMultiTurnBias(
2567
+ services: TDSServiceSchema[],
2568
+ history: LegendAIConversationTurn[],
2569
+ ): TDSServiceSchema[] {
2570
+ if (history.length === 0) {
2571
+ return services;
2572
+ }
2573
+ const previousAPs = extractPreviousTurnAPPatterns(history);
2574
+ if (previousAPs.size === 0) {
2575
+ return services;
2576
+ }
2577
+
2578
+ // Sort so that previously-used APs come first, preserving relative order
2579
+ const biased = [...services].sort((a, b) => {
2580
+ const aUsed =
2581
+ previousAPs.has(a.pattern.replace(/^\//, '').toLowerCase()) ||
2582
+ previousAPs.has(a.title.toLowerCase())
2583
+ ? 1
2584
+ : 0;
2585
+ const bUsed =
2586
+ previousAPs.has(b.pattern.replace(/^\//, '').toLowerCase()) ||
2587
+ previousAPs.has(b.title.toLowerCase())
2588
+ ? 1
2589
+ : 0;
2590
+ return bUsed - aUsed;
1853
2591
  });
1854
- scored.sort((a, b) => b.score - a.score);
1855
- return scored.slice(0, limit).map((s) => s.svc);
2592
+ return biased;
1856
2593
  }
1857
2594
 
1858
2595
  async function selectBestServices(
@@ -1860,27 +2597,66 @@ async function selectBestServices(
1860
2597
  services: TDSServiceSchema[],
1861
2598
  context: LegendAIOperationContext,
1862
2599
  ): Promise<TDSServiceSchema[]> {
1863
- const { plugin, config, setMessages } = context;
2600
+ const { plugin, config, setMessages, history } = context;
1864
2601
  if (services.length <= 1) {
1865
2602
  return services;
1866
2603
  }
1867
2604
 
1868
- let candidates = services;
1869
- if (services.length > MAX_SERVICES_FOR_LLM_SELECTION) {
2605
+ // ── Step 0: Normalize the question (expand abbreviations, fix typos) ──
2606
+ addThinkingStep(setMessages, 'Normalizing question...');
2607
+ const normalizedQuestion = await normalizeQuestion(question, plugin, config);
2608
+ if (normalizedQuestion !== question) {
2609
+ addThinkingStep(setMessages, `Normalized: "${normalizedQuestion}"`);
2610
+ }
2611
+
2612
+ // ── Step 0b: Apply multi-turn bias if this is a follow-up ──
2613
+ let inputServices = services;
2614
+ if (history.length > 0) {
2615
+ inputServices = applyMultiTurnBias(services, history);
2616
+ }
2617
+
2618
+ let candidates = inputServices;
2619
+ if (inputServices.length > MAX_SERVICES_FOR_LLM_SELECTION) {
1870
2620
  candidates = preFilterServicesByRelevance(
1871
- question,
1872
- services,
2621
+ normalizedQuestion,
2622
+ inputServices,
1873
2623
  MAX_SERVICES_FOR_LLM_SELECTION,
1874
2624
  );
2625
+ const topNames = candidates
2626
+ .slice(0, 5)
2627
+ .map((s) => `${s.title} (${s.columns.length} cols)`)
2628
+ .join(', ');
1875
2629
  addThinkingStep(
1876
2630
  setMessages,
1877
- `Pre-filtered ${services.length} services to ${candidates.length} candidates`,
2631
+ `Pre-filtered ${services.length} services to ${candidates.length} candidates. Top: ${topNames}`,
1878
2632
  );
1879
2633
  }
1880
2634
 
1881
2635
  try {
1882
2636
  addThinkingStep(setMessages, 'Selecting best service for your query...');
1883
- return await plugin.selectRelevantServices(question, candidates, config);
2637
+ const selected = await plugin.selectRelevantServices(
2638
+ normalizedQuestion,
2639
+ candidates,
2640
+ config,
2641
+ );
2642
+ const selectedNames = selected.map((s) => s.title).join(', ');
2643
+ addThinkingStep(setMessages, `LLM selected: ${selectedNames}`);
2644
+ const validated = supplementMissingCoverage(
2645
+ normalizedQuestion,
2646
+ selected,
2647
+ candidates,
2648
+ );
2649
+ if (validated.length > selected.length) {
2650
+ const supplemented = validated
2651
+ .slice(selected.length)
2652
+ .map((s) => s.title)
2653
+ .join(', ');
2654
+ addThinkingStep(
2655
+ setMessages,
2656
+ `Supplemented with ${supplemented} for column coverage`,
2657
+ );
2658
+ }
2659
+ return validated;
1884
2660
  } catch (selectionError) {
1885
2661
  assertErrorThrown(selectionError);
1886
2662
  addThinkingStep(
@@ -1952,6 +2728,121 @@ async function tryRecoverZeroRows(
1952
2728
  return { sql: recoveredSql, result: recoveredResult };
1953
2729
  }
1954
2730
 
2731
+ async function resolveNestedPCalls(
2732
+ sql: string,
2733
+ question: string,
2734
+ selectedAPs: TDSServiceSchema[],
2735
+ context: LegendAIOperationContext,
2736
+ modelContextEnrichment?: string,
2737
+ ): Promise<string> {
2738
+ if (!hasNestedPCalls(sql)) {
2739
+ return sql;
2740
+ }
2741
+ const { plugin, config, setMessages } = context;
2742
+ addThinkingStep(setMessages, 'Detected nested p() call — rewriting query...');
2743
+ const fixPrompt = plugin.buildAccessPointErrorCorrectionPrompt(
2744
+ sql,
2745
+ question,
2746
+ 'The SQL contains p() inside a subquery (IN clause, CROSS JOIN, or scalar subquery). p() can ONLY appear directly after FROM or JOIN.',
2747
+ selectedAPs,
2748
+ context.history,
2749
+ modelContextEnrichment,
2750
+ );
2751
+ try {
2752
+ const fixResponse = await plugin.callLLM(fixPrompt, config);
2753
+ const fixResult = plugin.extractJudgeResult(fixResponse);
2754
+ const corrected = fixResult.correctedSql;
2755
+ if (corrected && !hasNestedPCalls(corrected)) {
2756
+ return corrected;
2757
+ }
2758
+ } catch (fixError) {
2759
+ assertErrorThrown(fixError);
2760
+ addThinkingStep(
2761
+ setMessages,
2762
+ `Could not rewrite query: ${fixError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}. Proceeding with original SQL.`,
2763
+ );
2764
+ }
2765
+ return sql;
2766
+ }
2767
+
2768
+ interface AccessPointSqlFixOptions {
2769
+ failedSql: string;
2770
+ question: string;
2771
+ errMsg: string;
2772
+ selectedAPs: TDSServiceSchema[];
2773
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates;
2774
+ modelContextEnrichment?: string;
2775
+ startTime: number;
2776
+ }
2777
+
2778
+ async function retryAccessPointSqlFix(
2779
+ options: AccessPointSqlFixOptions,
2780
+ context: LegendAIOperationContext,
2781
+ metadata: LegendAIProductMetadata,
2782
+ ): Promise<boolean> {
2783
+ const {
2784
+ failedSql,
2785
+ question,
2786
+ errMsg,
2787
+ selectedAPs,
2788
+ dataProductCoordinates,
2789
+ modelContextEnrichment,
2790
+ startTime,
2791
+ } = options;
2792
+ const { plugin, config, setMessages } = context;
2793
+ addThinkingStep(
2794
+ setMessages,
2795
+ 'SQL error detected — asking LLM to fix the query on the same access points...',
2796
+ );
2797
+ const fixPrompt = plugin.buildAccessPointErrorCorrectionPrompt(
2798
+ failedSql,
2799
+ question,
2800
+ errMsg.slice(0, MAX_ERROR_MESSAGE_LENGTH),
2801
+ selectedAPs,
2802
+ context.history,
2803
+ modelContextEnrichment,
2804
+ );
2805
+ try {
2806
+ const fixResponse = await plugin.callLLM(fixPrompt, config);
2807
+ const fixResult = plugin.extractJudgeResult(fixResponse);
2808
+ const correctedSql = fixResult.correctedSql;
2809
+ if (correctedSql) {
2810
+ addThinkingStep(setMessages, 'Re-executing corrected SQL...');
2811
+ updateLastAssistant(setMessages, () => ({
2812
+ sql: correctedSql,
2813
+ isExecuting: true,
2814
+ }));
2815
+ const retryResult = await executeSqlForServices(
2816
+ correctedSql,
2817
+ selectedAPs,
2818
+ dataProductCoordinates,
2819
+ plugin,
2820
+ config,
2821
+ );
2822
+ await reportQueryResults(
2823
+ {
2824
+ currentSql: correctedSql,
2825
+ sqlResult: retryResult,
2826
+ question,
2827
+ services: selectedAPs,
2828
+ },
2829
+ metadata,
2830
+ context,
2831
+ startTime,
2832
+ false,
2833
+ );
2834
+ return true;
2835
+ }
2836
+ } catch (retryError) {
2837
+ assertErrorThrown(retryError);
2838
+ addThinkingStep(
2839
+ setMessages,
2840
+ `SQL correction also failed: ${retryError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2841
+ );
2842
+ }
2843
+ return false;
2844
+ }
2845
+
1955
2846
  /**
1956
2847
  * Dedicated query flow for data product access points.
1957
2848
  * Unlike processDataQuery this:
@@ -1965,9 +2856,9 @@ async function processAccessPointQuery(
1965
2856
  context: LegendAIOperationContext,
1966
2857
  startTime: number,
1967
2858
  dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
2859
+ modelContextEnrichment?: string,
1968
2860
  ): Promise<void> {
1969
2861
  const { config, plugin, setMessages } = context;
1970
- const hasOrchestratorFallback = Boolean(config.orchestratorUrl);
1971
2862
 
1972
2863
  addThinkingStep(setMessages, 'Found relevant access points to query');
1973
2864
 
@@ -1978,6 +2869,7 @@ async function processAccessPointQuery(
1978
2869
  selectedAPs,
1979
2870
  context,
1980
2871
  startTime,
2872
+ modelContextEnrichment,
1981
2873
  );
1982
2874
 
1983
2875
  if (!judgedSql) {
@@ -1988,7 +2880,7 @@ async function processAccessPointQuery(
1988
2880
  handleSqlGenerationFailure(
1989
2881
  setMessages,
1990
2882
  startTime,
1991
- hasOrchestratorFallback,
2883
+ false,
1992
2884
  SQL_GENERATION_FAILURE_WITH_ORCHESTRATOR,
1993
2885
  SQL_GENERATION_FAILURE_NO_ORCHESTRATOR,
1994
2886
  LegendAIErrorType.GENERATION,
@@ -1999,24 +2891,34 @@ async function processAccessPointQuery(
1999
2891
 
2000
2892
  const sqlGenTimeValue = elapsedSeconds(startTime, 2);
2001
2893
  completeThinkingSteps(setMessages);
2894
+
2895
+ const finalSql = await resolveNestedPCalls(
2896
+ judgedSql,
2897
+ question,
2898
+ selectedAPs,
2899
+ context,
2900
+ modelContextEnrichment,
2901
+ );
2902
+
2002
2903
  updateLastAssistant(setMessages, () => ({
2003
- sql: judgedSql,
2904
+ sql: finalSql,
2004
2905
  sqlGenTime: sqlGenTimeValue,
2005
2906
  isExecuting: true,
2006
2907
  }));
2007
2908
 
2008
2909
  const execStartTime = Date.now();
2009
2910
  try {
2010
- const safeSql = ensureSafeLimit(judgedSql);
2011
- const rawResult = await plugin.executeLakehouseSql(
2012
- safeSql,
2911
+ const rawResult = await executeSqlForServices(
2912
+ finalSql,
2913
+ selectedAPs,
2013
2914
  dataProductCoordinates,
2915
+ plugin,
2014
2916
  config,
2015
2917
  );
2016
2918
 
2017
2919
  await reportQueryResults(
2018
2920
  {
2019
- currentSql: judgedSql,
2921
+ currentSql: finalSql,
2020
2922
  sqlResult: rawResult,
2021
2923
  question,
2022
2924
  services: selectedAPs,
@@ -2024,35 +2926,60 @@ async function processAccessPointQuery(
2024
2926
  metadata,
2025
2927
  context,
2026
2928
  startTime,
2027
- hasOrchestratorFallback,
2929
+ false,
2028
2930
  );
2029
2931
  } catch (executeError) {
2030
2932
  assertErrorThrown(executeError);
2031
2933
  const execErrorType = classifyError(executeError);
2934
+ const errMsg = executeError.message;
2935
+ const errorCategory = categorizeExecutionError(errMsg, executeError);
2936
+
2032
2937
  addThinkingStep(
2033
2938
  setMessages,
2034
- `Execution failed: ${executeError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2939
+ `Execution failed: ${errMsg.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2035
2940
  );
2941
+
2942
+ if (errorCategory === ExecutionErrorCategory.SQL_FIXABLE) {
2943
+ const retried = await retryAccessPointSqlFix(
2944
+ {
2945
+ failedSql: finalSql,
2946
+ question,
2947
+ errMsg,
2948
+ selectedAPs,
2949
+ dataProductCoordinates,
2950
+ ...(modelContextEnrichment === undefined
2951
+ ? {}
2952
+ : { modelContextEnrichment }),
2953
+ startTime,
2954
+ },
2955
+ context,
2956
+ metadata,
2957
+ );
2958
+ if (retried) {
2959
+ return;
2960
+ }
2961
+ }
2962
+
2036
2963
  finishWithThinkingError(
2037
2964
  setMessages,
2038
- buildExecutionErrorMessage(executeError.message, selectedAPs),
2965
+ buildExecutionErrorMessage(errMsg, selectedAPs),
2039
2966
  startTime,
2040
2967
  execErrorType === LegendAIErrorType.GENERAL
2041
2968
  ? LegendAIErrorType.EXECUTION
2042
2969
  : execErrorType,
2043
2970
  );
2971
+ const queriedGroups = [
2972
+ ...new Set(
2973
+ selectedAPs
2974
+ .map((ap) => ap.accessPointGroupTitle)
2975
+ .filter((t): t is string => t !== undefined),
2976
+ ),
2977
+ ];
2044
2978
  updateLastAssistant(setMessages, () => ({
2045
2979
  execTime: elapsedSeconds(execStartTime, 2),
2046
2980
  isExecuting: false,
2047
2981
  suggestedQueries: buildFallbackSuggestions(selectedAPs),
2048
- ...(hasOrchestratorFallback
2049
- ? {
2050
- fallbackAction: {
2051
- label: ORCHESTRATOR_FALLBACK_LABEL,
2052
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2053
- },
2054
- }
2055
- : {}),
2982
+ queriedAccessPointGroups: queriedGroups,
2056
2983
  }));
2057
2984
  }
2058
2985
  }
@@ -2065,6 +2992,7 @@ async function processDataQuery(
2065
2992
  context: LegendAIOperationContext,
2066
2993
  startTime: number,
2067
2994
  orchestratorOptions?: LegendAIOrchestratorOptionsParam,
2995
+ modelContextEnrichment?: string,
2068
2996
  ): Promise<void> {
2069
2997
  const { config, setMessages } = context;
2070
2998
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
@@ -2099,6 +3027,7 @@ async function processDataQuery(
2099
3027
  context,
2100
3028
  startTime,
2101
3029
  metadata,
3030
+ modelContextEnrichment,
2102
3031
  );
2103
3032
 
2104
3033
  if (!judgedSql) {
@@ -2242,6 +3171,7 @@ async function routeToAccessPointOrData(
2242
3171
  context: LegendAIOperationContext,
2243
3172
  startTime: number,
2244
3173
  orchestratorOpts: LegendAIOrchestratorOptionsParam | undefined,
3174
+ modelContextEnrichment?: string,
2245
3175
  ): Promise<void> {
2246
3176
  const dataProductCoordinates = orchestratorOpts?.dataProductCoordinates;
2247
3177
  const { tdsServices, accessPoints } = splitServicesByType(services);
@@ -2257,6 +3187,7 @@ async function routeToAccessPointOrData(
2257
3187
  context,
2258
3188
  startTime,
2259
3189
  dataProductCoordinates,
3190
+ modelContextEnrichment,
2260
3191
  );
2261
3192
  } else {
2262
3193
  await processDataQuery(
@@ -2267,59 +3198,11 @@ async function routeToAccessPointOrData(
2267
3198
  context,
2268
3199
  startTime,
2269
3200
  orchestratorOpts,
3201
+ modelContextEnrichment,
2270
3202
  );
2271
3203
  }
2272
3204
  }
2273
3205
 
2274
- async function handleAmbiguousIntent(
2275
- question: string,
2276
- services: TDSServiceSchema[],
2277
- coordinates: string,
2278
- metadata: LegendAIProductMetadata,
2279
- context: LegendAIOperationContext,
2280
- startTime: number,
2281
- orchestratorOpts: LegendAIOrchestratorOptionsParam | undefined,
2282
- ): Promise<void> {
2283
- const { setMessages } = context;
2284
- addThinkingStep(
2285
- setMessages,
2286
- 'Intent is ambiguous, providing metadata context and querying data...',
2287
- );
2288
- let metadataOverview: string | undefined;
2289
- try {
2290
- addThinkingStep(setMessages, 'Building metadata context...');
2291
- metadataOverview = await buildMetadataOverview(question, metadata, context);
2292
- } catch (metadataError) {
2293
- assertErrorThrown(metadataError);
2294
- addThinkingStep(
2295
- setMessages,
2296
- `Metadata context failed: ${metadataError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2297
- );
2298
- }
2299
-
2300
- try {
2301
- await routeToAccessPointOrData(
2302
- question,
2303
- services,
2304
- coordinates,
2305
- metadata,
2306
- context,
2307
- startTime,
2308
- orchestratorOpts,
2309
- );
2310
- if (metadataOverview) {
2311
- attachMetadataOverview(setMessages, metadataOverview);
2312
- }
2313
- } catch (queryError) {
2314
- assertErrorThrown(queryError);
2315
- addThinkingStep(
2316
- setMessages,
2317
- 'Query failed, answering from product metadata...',
2318
- );
2319
- await handleMetadataQuestion(question, metadata, context, startTime, true);
2320
- }
2321
- }
2322
-
2323
3206
  export async function processQuestion(
2324
3207
  question: string,
2325
3208
  services: TDSServiceSchema[],
@@ -2328,9 +3211,13 @@ export async function processQuestion(
2328
3211
  context: LegendAIOperationContext,
2329
3212
  dataProductCoordinates?: LegendAIOrchestratorDataProductCoordinates,
2330
3213
  pureExecutionContext?: QueryExplicitExecutionContextInfo,
3214
+ modelContext?: LegendAIModelContext,
2331
3215
  ): Promise<void> {
2332
3216
  const { config, plugin, setMessages } = context;
2333
3217
  const startTime = Date.now();
3218
+ const modelContextEnrichment = modelContext
3219
+ ? buildModelContextEnrichmentText(modelContext, services)
3220
+ : undefined;
2334
3221
 
2335
3222
  try {
2336
3223
  addThinkingStep(setMessages, 'Analyzing your question...');
@@ -2351,6 +3238,8 @@ export async function processQuestion(
2351
3238
  context,
2352
3239
  startTime,
2353
3240
  false,
3241
+ services,
3242
+ modelContextEnrichment,
2354
3243
  );
2355
3244
  if (config.orchestratorUrl && dataProductCoordinates) {
2356
3245
  updateLastAssistant(setMessages, () => ({
@@ -2363,34 +3252,6 @@ export async function processQuestion(
2363
3252
  return;
2364
3253
  }
2365
3254
 
2366
- const fastIntent = classifyQuestionIntentFast(question, true);
2367
- if (
2368
- fastIntent.intent === LegendAIQuestionIntent.METADATA &&
2369
- !fastIntent.ambiguous
2370
- ) {
2371
- await handleMetadataQuestion(
2372
- question,
2373
- metadata,
2374
- context,
2375
- startTime,
2376
- true,
2377
- );
2378
- return;
2379
- }
2380
-
2381
- if (fastIntent.ambiguous) {
2382
- await handleAmbiguousIntent(
2383
- question,
2384
- services,
2385
- coordinates,
2386
- metadata,
2387
- context,
2388
- startTime,
2389
- orchestratorOpts,
2390
- );
2391
- return;
2392
- }
2393
-
2394
3255
  const serviceNames = services.map((s) => s.title);
2395
3256
  const intent = await plugin.classifyQuestionIntent(
2396
3257
  question,
@@ -2406,6 +3267,8 @@ export async function processQuestion(
2406
3267
  context,
2407
3268
  startTime,
2408
3269
  true,
3270
+ services,
3271
+ modelContextEnrichment,
2409
3272
  );
2410
3273
  return;
2411
3274
  }
@@ -2419,12 +3282,13 @@ export async function processQuestion(
2419
3282
  context,
2420
3283
  startTime,
2421
3284
  orchestratorOpts,
3285
+ modelContextEnrichment,
2422
3286
  );
2423
3287
  } catch (sqlError) {
2424
3288
  assertErrorThrown(sqlError);
2425
3289
  addThinkingStep(
2426
3290
  setMessages,
2427
- 'SQL generation failed, answering from product metadata...',
3291
+ 'Query failed, answering from product metadata...',
2428
3292
  );
2429
3293
  await handleMetadataQuestion(
2430
3294
  question,
@@ -2432,6 +3296,8 @@ export async function processQuestion(
2432
3296
  context,
2433
3297
  startTime,
2434
3298
  true,
3299
+ services,
3300
+ modelContextEnrichment,
2435
3301
  );
2436
3302
  appendFallbackSuggestions(setMessages, services);
2437
3303
  }
@@ -2458,6 +3324,7 @@ async function routeDataQueryWithErrorHandling(
2458
3324
  metadata: LegendAIProductMetadata,
2459
3325
  context: LegendAIOperationContext,
2460
3326
  orchestratorOptions: LegendAIOrchestratorOptionsParam | undefined,
3327
+ modelContextEnrichment?: string,
2461
3328
  ): Promise<void> {
2462
3329
  const { setMessages } = context;
2463
3330
  const startTime = Date.now();
@@ -2471,6 +3338,7 @@ async function routeDataQueryWithErrorHandling(
2471
3338
  context,
2472
3339
  startTime,
2473
3340
  orchestratorOptions,
3341
+ modelContextEnrichment,
2474
3342
  );
2475
3343
  } catch (error) {
2476
3344
  assertErrorThrown(error);
@@ -2495,9 +3363,13 @@ export async function processQuestionWithIntent(
2495
3363
  metadata: LegendAIProductMetadata,
2496
3364
  context: LegendAIOperationContext,
2497
3365
  orchestratorOptions?: LegendAIOrchestratorOptionsParam,
3366
+ modelContext?: LegendAIModelContext,
2498
3367
  ): Promise<void> {
2499
3368
  const { config, setMessages } = context;
2500
3369
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
3370
+ const modelContextEnrichment = modelContext
3371
+ ? buildModelContextEnrichmentText(modelContext, services)
3372
+ : undefined;
2501
3373
 
2502
3374
  if (intent === LegendAIQuestionIntent.METADATA) {
2503
3375
  const startTime = Date.now();
@@ -2508,6 +3380,8 @@ export async function processQuestionWithIntent(
2508
3380
  context,
2509
3381
  startTime,
2510
3382
  services.length > 0,
3383
+ services,
3384
+ modelContextEnrichment,
2511
3385
  );
2512
3386
  } catch (error) {
2513
3387
  assertErrorThrown(error);
@@ -2538,11 +3412,20 @@ export async function processQuestionWithIntent(
2538
3412
  metadata,
2539
3413
  context,
2540
3414
  orchestratorOptions,
3415
+ modelContextEnrichment,
2541
3416
  );
2542
3417
  return;
2543
3418
  }
2544
3419
  const startTime = Date.now();
2545
- await handleMetadataQuestion(question, metadata, context, startTime, false);
3420
+ await handleMetadataQuestion(
3421
+ question,
3422
+ metadata,
3423
+ context,
3424
+ startTime,
3425
+ false,
3426
+ services,
3427
+ modelContextEnrichment,
3428
+ );
2546
3429
  updateLastAssistant(setMessages, () => ({
2547
3430
  fallbackAction: {
2548
3431
  label: ORCHESTRATOR_FALLBACK_LABEL,
@@ -2559,5 +3442,6 @@ export async function processQuestionWithIntent(
2559
3442
  metadata,
2560
3443
  context,
2561
3444
  orchestratorOptions,
3445
+ modelContextEnrichment,
2562
3446
  );
2563
3447
  }