@finos/legend-lego 2.0.211 → 2.0.213

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 (74) 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 +12 -12
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +317 -86
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIHostIntegration.d.ts +31 -0
  8. package/lib/legend-ai/LegendAIHostIntegration.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIHostIntegration.js +50 -0
  10. package/lib/legend-ai/LegendAIHostIntegration.js.map +1 -0
  11. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -1
  12. package/lib/legend-ai/LegendAIServiceRetrieval.js +34 -35
  13. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -1
  14. package/lib/legend-ai/LegendAITypes.d.ts +85 -7
  15. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  16. package/lib/legend-ai/LegendAITypes.js +99 -1
  17. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +36 -3
  19. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  20. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +29 -0
  21. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  23. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +7 -0
  24. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  25. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  26. package/lib/legend-ai/components/LegendAICharts.js +6 -10
  27. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  28. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  29. package/lib/legend-ai/components/LegendAIChat.js +263 -41
  30. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  31. package/lib/legend-ai/components/LegendAIChatInput.d.ts +1 -1
  32. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  33. package/lib/legend-ai/components/LegendAIChatInput.js +2 -1
  34. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  35. package/lib/legend-ai/index.d.ts +5 -2
  36. package/lib/legend-ai/index.d.ts.map +1 -1
  37. package/lib/legend-ai/index.js +5 -2
  38. package/lib/legend-ai/index.js.map +1 -1
  39. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +9 -3
  40. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  41. package/lib/legend-ai/stores/LegendAIChatProcessors.js +794 -220
  42. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  43. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  44. package/lib/legend-ai/stores/LegendAIChatState.js +47 -21
  45. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  46. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +29 -0
  47. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -0
  48. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +124 -0
  49. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -0
  50. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +25 -0
  51. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -0
  52. package/lib/legend-ai/stores/LegendAISqlHelpers.js +64 -0
  53. package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -0
  54. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts +22 -0
  55. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts.map +1 -0
  56. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +411 -0
  57. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/legend-ai/LegendAIDocEnrichment.ts +435 -97
  60. package/src/legend-ai/LegendAIHostIntegration.tsx +80 -0
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +50 -35
  62. package/src/legend-ai/LegendAITypes.ts +169 -5
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +73 -1
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +10 -0
  65. package/src/legend-ai/components/LegendAICharts.tsx +30 -33
  66. package/src/legend-ai/components/LegendAIChat.tsx +555 -91
  67. package/src/legend-ai/components/LegendAIChatInput.tsx +6 -2
  68. package/src/legend-ai/index.ts +22 -3
  69. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1286 -322
  70. package/src/legend-ai/stores/LegendAIChatState.ts +73 -28
  71. package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +165 -0
  72. package/src/legend-ai/stores/LegendAISqlHelpers.ts +82 -0
  73. package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +515 -0
  74. package/tsconfig.json +4 -0
@@ -15,7 +15,12 @@
15
15
  */
16
16
 
17
17
  import type React from 'react';
18
- import { assertErrorThrown, isPlainObject, uuid } from '@finos/legend-shared';
18
+ import {
19
+ assertErrorThrown,
20
+ guaranteeNonNullable,
21
+ isNonNullable,
22
+ uuid,
23
+ } from '@finos/legend-shared';
19
24
  import {
20
25
  type TDSServiceSchema,
21
26
  type LegendAIConfig,
@@ -25,16 +30,20 @@ import {
25
30
  type LegendAIConversationTurn,
26
31
  type LegendAIProductMetadata,
27
32
  type LegendAIModelContext,
33
+ LegendAISelfHealKind,
28
34
  LegendAIQuestionIntent,
29
35
  LegendAIResponseOutcome,
30
36
  LegendAIThinkingStepStatus,
31
37
  LegendAIMessageRole,
32
38
  LegendAIErrorType,
39
+ LegendAIExecutionTimeoutError,
33
40
  LegendAIServiceError,
34
41
  LegendAIUnsupportedEngineShapeError,
35
42
  TDSServiceSourceType,
36
43
  buildColumnDefsFromNames,
37
44
  LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
45
+ LEGEND_AI_ALTERNATE_ROOT_ACTION_ID,
46
+ LEGEND_AI_FEEDBACK_PROMPT,
38
47
  getTodayISO,
39
48
  } from '../LegendAITypes.js';
40
49
  import {
@@ -46,12 +55,19 @@ import {
46
55
  } from '../LegendAI_LegendApplicationPlugin_Extension.js';
47
56
  import {
48
57
  type QueryExplicitExecutionContextInfo,
58
+ type TDSRowDataType,
49
59
  extractElementNameFromPath,
50
60
  } from '@finos/legend-graph';
51
61
  import {
52
62
  buildEnrichedBusinessContext,
53
63
  buildModelContextEnrichmentText,
64
+ buildModelCatalogText,
65
+ buildDataQueryApproachText,
54
66
  findBestAlternateRoot,
67
+ rankEntities,
68
+ relaxExactStringFilters,
69
+ extractFilteredColumns,
70
+ buildProbedValueHints,
55
71
  splitIdentifierTokens,
56
72
  tokenizeText,
57
73
  } from '../LegendAIDocEnrichment.js';
@@ -63,30 +79,49 @@ import {
63
79
  isNumericColumn,
64
80
  isStringColumn,
65
81
  } from '../components/LegendAIChatHelpers.js';
82
+ import {
83
+ HAS_LIMIT_PATTERN,
84
+ AP_CALL_PATTERN,
85
+ servicePId,
86
+ pureRelationColumnRef,
87
+ } from './LegendAISqlHelpers.js';
88
+ import {
89
+ buildCrossJoinZeroRowExplanation,
90
+ buildJoinablePairSuggestions,
91
+ } from './LegendAIJoinAnalysis.js';
92
+ import {
93
+ boundCrossAccessPointJoinDrivingSide,
94
+ sanitizeJoinDuplicateColumns,
95
+ sanitizeJoinOrderBy,
96
+ sanitizeJoinSameKeyColumns,
97
+ wrapBareJoinAccessPoints,
98
+ } from './LegendAISqlJoinSanitizers.js';
66
99
 
67
100
  const MAX_ERROR_MESSAGE_LENGTH = 500;
101
+ const MAX_DISPLAYED_ERROR_LENGTH = 4000;
68
102
  const MAX_THINKING_ERROR_PREVIEW_LENGTH = 200;
69
103
  const DEFAULT_MAX_JUDGE_ATTEMPTS = 5;
70
104
  const DEFAULT_MAX_EXECUTION_RETRIES = 3;
71
105
  const ANALYSIS_TIMEOUT_MS = 15_000;
72
106
  const ORCHESTRATOR_GENERATION_TIMEOUT_MS = 120_000;
107
+ const DISTINCT_PROBE_ROW_LIMIT = 5000;
108
+ const MAX_RECOVERED_FILTER_COLUMNS = 4;
73
109
  const EXECUTION_TIMEOUT_MS = 300_000;
74
110
  const ANALYSIS_PREVIEW_ROW_LIMIT = 3;
75
111
  const ANALYSIS_PREVIEW_VALUE_LIMIT = 40;
112
+ const MAX_ANALYSIS_ROWS = 100;
76
113
  const MAX_NON_SQL_PASS_ATTEMPTS = 2;
77
- const JOIN_PATTERN = /\bJOIN\b/i;
78
- const ORDER_BY_SPLIT = /\bORDER\s+BY\b/i;
79
114
  const UNION_ALL_PATTERN = /\bUNION\s+ALL\b/i;
80
115
  const LITERAL_COL_PATTERN = /,\s*'[^']*'\s+AS\s+(?:"[^"]+"|[a-z]\w*)/gi;
81
- const SELECT_ALIAS_PATTERN =
82
- /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
83
- const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
84
116
  const SERVICE_CALL_PATTERN = /\bservice\s*\([^()]*\)/gi;
85
117
  const DEFAULT_SAFETY_LIMIT = 1000;
86
- const HAS_LIMIT_PATTERN = /\bLIMIT\s+\d+/i;
87
118
  const HAS_AGGREGATION_PATTERN =
88
- /\bGROUP\s+BY\b|\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(|\bMIN\s*\(|\bMAX\s*\(/i;
119
+ /\bGROUP\s+BY\b|\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(|\bSELECT\s+DISTINCT\b|\bHAVING\b|\bQUALIFY\b/i;
89
120
  const MAX_SERVICES_FOR_LLM_SELECTION = 30;
121
+ const VALUE_GROUNDING_TIME_BUDGET_MS = 4000;
122
+ const AP_SQL_EXECUTION_TIMEOUT_MS = 90_000;
123
+ const AP_SQL_EXECUTION_TIMEOUT_MESSAGE =
124
+ 'This query is scanning very large access-point feeds and is taking too long to return. Narrow it with a date or entity filter, join on the full key (including the date), or use a *_LATEST snapshot access point.';
90
125
  const ORCHESTRATOR_FALLBACK_LABEL = 'Try Legend AI Orchestrator';
91
126
  const SQL_GENERATION_FAILURE_WITH_ORCHESTRATOR =
92
127
  'SQL generation could not handle this query. You can try the Legend AI Orchestrator to generate a Pure query instead.';
@@ -99,6 +134,13 @@ const SERVICE_PARAM_DATE_LIKE_PATTERNS: readonly RegExp[] = [
99
134
  ];
100
135
  const GENERIC_TABLE_PATTERNS = /combined|consolidated|all|master|summary/i;
101
136
 
137
+ const MIN_ALL_NULL_COLUMNS_FOR_JOIN_HINT = 2;
138
+ const JOIN_HINT_COLUMN_PREVIEW_LIMIT = 3;
139
+
140
+ const MAX_AGGREGATE_COLUMNS = 12;
141
+ const MAX_AGGREGATE_VALUES_PER_COLUMN = 8;
142
+ const MIN_NUMERIC_DISTINCT_RATIO = 0.5;
143
+
102
144
  function isLikelySqlQuery(text: string): boolean {
103
145
  const trimmed = text.trim().toLowerCase();
104
146
  return (
@@ -167,6 +209,7 @@ export function createMessagePair(
167
209
  suggestedQueries: [],
168
210
  fallbackAction: null,
169
211
  queriedAccessPointGroups: [],
212
+ queriedAccessPoints: [],
170
213
  },
171
214
  ];
172
215
  }
@@ -272,6 +315,9 @@ export function categorizeExecutionError(
272
315
  errMsg: string,
273
316
  error?: Error,
274
317
  ): ExecutionErrorCategory {
318
+ if (error instanceof LegendAIExecutionTimeoutError) {
319
+ return ExecutionErrorCategory.NONE;
320
+ }
275
321
  if (error instanceof LegendAIUnsupportedEngineShapeError) {
276
322
  return ExecutionErrorCategory.SQL_FIXABLE;
277
323
  }
@@ -289,6 +335,15 @@ export function categorizeExecutionError(
289
335
  return ExecutionErrorCategory.NONE;
290
336
  }
291
337
 
338
+ const ACCESS_ERROR_PATTERN =
339
+ /insufficient privileges|access.*denied|permission.*denied|not authorized|unauthorized|403|entitlement/i;
340
+
341
+ // Whether an execution error message indicates an access/permission problem (as
342
+ // opposed to a SQL compilation or schema error), gating the "Request Access" UI.
343
+ export function looksLikeAccessError(errorMsg: string | undefined): boolean {
344
+ return errorMsg !== undefined && ACCESS_ERROR_PATTERN.test(errorMsg);
345
+ }
346
+
292
347
  export function finishWithThinkingError(
293
348
  setMessages: MessageSetter,
294
349
  errorMsg: string,
@@ -301,13 +356,47 @@ export function finishWithThinkingError(
301
356
  ? { ...s, status: LegendAIThinkingStepStatus.ERROR }
302
357
  : s,
303
358
  ),
304
- error: errorMsg.slice(0, MAX_ERROR_MESSAGE_LENGTH),
359
+ error: errorMsg.slice(0, MAX_DISPLAYED_ERROR_LENGTH),
305
360
  errorType: errorType ?? null,
306
361
  isProcessing: false,
307
362
  thinkingDuration: elapsedSeconds(startTime),
308
363
  }));
309
364
  }
310
365
 
366
+ export function filterHistoryForAccessPoints(
367
+ history: LegendAIConversationTurn[],
368
+ currentAccessPoints: ReadonlySet<string>,
369
+ ): LegendAIConversationTurn[] {
370
+ if (currentAccessPoints.size === 0) {
371
+ return history;
372
+ }
373
+ return history.filter((turn) => {
374
+ if (!turn.queriedAccessPoints || turn.queriedAccessPoints.length === 0) {
375
+ return true;
376
+ }
377
+ return turn.queriedAccessPoints.some((ap) => currentAccessPoints.has(ap));
378
+ });
379
+ }
380
+
381
+ // Access-point names referenced by `p('...')` calls in the SQL, in
382
+ // first-appearance (driving-first) order and de-duplicated. Computed once at
383
+ // generation to persist on the message so downstream consumers (UI, history,
384
+ // integrations) resolve access points structurally instead of re-parsing SQL.
385
+ function orderedAccessPointsFromSql(sql: string): string[] {
386
+ const ordered: string[] = [];
387
+ const seen = new Set<string>();
388
+ for (const match of sql.matchAll(AP_CALL_PATTERN)) {
389
+ const path = match.groups?.pId ?? '';
390
+ const dotIdx = path.lastIndexOf('.');
391
+ const apName = dotIdx === -1 ? path : path.slice(dotIdx + 1);
392
+ if (apName.length > 0 && !seen.has(apName)) {
393
+ seen.add(apName);
394
+ ordered.push(apName);
395
+ }
396
+ }
397
+ return ordered;
398
+ }
399
+
311
400
  function buildTurnFromAssistant(
312
401
  userText: string,
313
402
  asstMsg: LegendAIAssistantMessage,
@@ -318,6 +407,9 @@ function buildTurnFromAssistant(
318
407
  sql: asstMsg.sql,
319
408
  intent: LegendAIQuestionIntent.DATA_QUERY,
320
409
  };
410
+ if (asstMsg.queriedAccessPoints.length > 0) {
411
+ turn.queriedAccessPoints = asstMsg.queriedAccessPoints;
412
+ }
321
413
  if (asstMsg.error) {
322
414
  turn.resultSummary = `ERROR: ${asstMsg.error.slice(0, 200)}`;
323
415
  } else if (asstMsg.gridData) {
@@ -530,10 +622,10 @@ export function buildExecutionErrorMessage(
530
622
  return errParts.join('');
531
623
  }
532
624
 
533
- if (errLower.includes('rename(~')) {
625
+ if (errLower.includes('duplicate column')) {
534
626
  return [
535
- 'Cross-access-point JOINs on columns with the same name are not yet supported by the execution engine.',
536
- '\nTry querying each access point separately, or ask a metadata question instead.',
627
+ 'This join produced two output columns with the same name, which the execution engine cannot return.',
628
+ '\nGive each selected column a unique alias (e.g. `<column> AS <accessPoint>_<column>`) so the joined result stays distinct.',
537
629
  ].join('');
538
630
  }
539
631
 
@@ -680,7 +772,7 @@ function formatPreviewValue(value: unknown): string {
680
772
  function buildJoinDiagnosticNote(
681
773
  query: string,
682
774
  columns: string[],
683
- rows: unknown[],
775
+ rows: TDSRowDataType[],
684
776
  ): string {
685
777
  if (!/\bJOIN\b/i.test(query) || rows.length === 0 || columns.length < 2) {
686
778
  return '';
@@ -690,11 +782,8 @@ function buildJoinDiagnosticNote(
690
782
  const someDataColumns: string[] = [];
691
783
  for (const col of columns) {
692
784
  const allNull = rows.every((row) => {
693
- if (isPlainObject(row)) {
694
- const val = row[col];
695
- return val === null || val === undefined;
696
- }
697
- return true;
785
+ const val = row[col];
786
+ return val === null || val === undefined;
698
787
  });
699
788
  if (allNull) {
700
789
  allNullColumns.push(col);
@@ -706,10 +795,10 @@ function buildJoinDiagnosticNote(
706
795
  if (
707
796
  allNullColumns.length > 0 &&
708
797
  someDataColumns.length > 0 &&
709
- allNullColumns.length >= 2
798
+ allNullColumns.length >= MIN_ALL_NULL_COLUMNS_FOR_JOIN_HINT
710
799
  ) {
711
800
  return (
712
- `Note: ${allNullColumns.length} columns (${allNullColumns.slice(0, 3).join(', ')}${allNullColumns.length > 3 ? ', ...' : ''}) ` +
801
+ `Note: ${allNullColumns.length} columns (${allNullColumns.slice(0, JOIN_HINT_COLUMN_PREVIEW_LIMIT).join(', ')}${allNullColumns.length > JOIN_HINT_COLUMN_PREVIEW_LIMIT ? ', ...' : ''}) ` +
713
802
  'returned entirely NULL values. This typically means the joined services do not share ' +
714
803
  'overlapping values for the join key. The services may track different records. ' +
715
804
  'Try querying each service separately to explore their data independently.'
@@ -719,25 +808,101 @@ function buildJoinDiagnosticNote(
719
808
  return '';
720
809
  }
721
810
 
811
+ interface ColumnDistribution {
812
+ column: string;
813
+ counts: Map<string, number>;
814
+ present: number;
815
+ numeric: number;
816
+ min: number;
817
+ max: number;
818
+ }
819
+
820
+ function createColumnDistribution(column: string): ColumnDistribution {
821
+ return {
822
+ column,
823
+ counts: new Map(),
824
+ present: 0,
825
+ numeric: 0,
826
+ min: Number.POSITIVE_INFINITY,
827
+ max: Number.NEGATIVE_INFINITY,
828
+ };
829
+ }
830
+
831
+ // Formats one accumulated column distribution into a single line:
832
+ // "(all empty)", a numeric range, or the top values with their counts.
833
+ function formatColumnDistribution(stats: ColumnDistribution): string {
834
+ const { column, counts, present, numeric, min, max } = stats;
835
+ if (present === 0) {
836
+ return `- ${column}: (all empty)`;
837
+ }
838
+ if (
839
+ numeric === present &&
840
+ counts.size > present * MIN_NUMERIC_DISTINCT_RATIO
841
+ ) {
842
+ return `- ${column}: numeric, range ${min}–${max} (${present} values)`;
843
+ }
844
+ const top = Array.from(counts.entries())
845
+ .sort((a, b) => b[1] - a[1])
846
+ .slice(0, MAX_AGGREGATE_VALUES_PER_COLUMN)
847
+ .map(([value, count]) => `${value} (${count})`)
848
+ .join(', ');
849
+ const more =
850
+ counts.size > MAX_AGGREGATE_VALUES_PER_COLUMN
851
+ ? `, … ${counts.size} distinct total`
852
+ : '';
853
+ return `- ${column}: ${top}${more}`;
854
+ }
855
+
856
+ export function buildDataAggregatesText(
857
+ columns: string[],
858
+ rows: TDSRowDataType[],
859
+ ): string {
860
+ if (rows.length === 0) {
861
+ return 'Total rows: 0';
862
+ }
863
+ const distributions = columns
864
+ .slice(0, MAX_AGGREGATE_COLUMNS)
865
+ .map(createColumnDistribution);
866
+ for (const row of rows) {
867
+ for (const stats of distributions) {
868
+ const raw = row[stats.column];
869
+ if (raw === null || raw === undefined || raw === '') {
870
+ continue;
871
+ }
872
+ stats.present += 1;
873
+ const asString = String(raw);
874
+ stats.counts.set(asString, (stats.counts.get(asString) ?? 0) + 1);
875
+ const asNumber = typeof raw === 'number' ? raw : Number(asString);
876
+ if (asString.trim() !== '' && !Number.isNaN(asNumber)) {
877
+ stats.numeric += 1;
878
+ stats.min = Math.min(stats.min, asNumber);
879
+ stats.max = Math.max(stats.max, asNumber);
880
+ }
881
+ }
882
+ }
883
+ return [
884
+ `Total rows: ${rows.length}`,
885
+ 'Column value distributions over ALL rows (value: count):',
886
+ ...distributions.map(formatColumnDistribution),
887
+ ].join('\n');
888
+ }
889
+
722
890
  function buildDeterministicResultSummary(
723
891
  question: string,
724
892
  query: string,
725
893
  columns: string[],
726
- rows: unknown[],
894
+ rows: TDSRowDataType[],
727
895
  ): string {
728
896
  const selectedColumns = columns.length === 0 ? 'none' : columns.join(', ');
729
897
  const rowCount = rows.length;
730
898
  const previewRows = rows
731
899
  .slice(0, ANALYSIS_PREVIEW_ROW_LIMIT)
732
900
  .map((row, index) => {
733
- if (isPlainObject(row)) {
734
- const entries = Object.entries(row).slice(0, 4);
735
- const formattedEntries = entries.map(
736
- ([key, value]) => `${key}: ${formatPreviewValue(value)}`,
737
- );
738
- return `${index + 1}. ${formattedEntries.join(', ')}`;
739
- }
740
- return `${index + 1}. ${formatPreviewValue(row)}`;
901
+ const entries = Object.entries(row).slice(0, 4);
902
+ const formattedEntries = entries.map(
903
+ ([key, value]) => `${key}: ${formatPreviewValue(value)}`,
904
+ );
905
+ return `${index + 1}. ${formattedEntries.join(', ')}`;
741
906
  })
742
907
  .join('\n');
743
908
 
@@ -867,7 +1032,11 @@ function finalizeJudgeAttempt(
867
1032
  maxAttempts: number,
868
1033
  setMessages: MessageSetter,
869
1034
  ): string | null | undefined {
870
- if (currentSql !== previousSql && attempt < maxAttempts) {
1035
+ if (
1036
+ normalizeSqlForComparison(currentSql) !==
1037
+ normalizeSqlForComparison(previousSql) &&
1038
+ attempt < maxAttempts
1039
+ ) {
871
1040
  return undefined;
872
1041
  }
873
1042
  addThinkingStep(
@@ -1232,9 +1401,11 @@ export async function analyzeOrchestratorResults(
1232
1401
  question,
1233
1402
  query,
1234
1403
  execResult.columns,
1235
- execResult.rows,
1404
+ execResult.rows.slice(0, MAX_ANALYSIS_ROWS),
1236
1405
  metadata,
1237
1406
  config,
1407
+ execResult.rows.length,
1408
+ buildDataAggregatesText(execResult.columns, execResult.rows),
1238
1409
  ),
1239
1410
  ANALYSIS_TIMEOUT_MS,
1240
1411
  );
@@ -1360,6 +1531,207 @@ async function retryWithAlternateRoot(
1360
1531
  }
1361
1532
  }
1362
1533
 
1534
+ async function attemptProbedRegeneration(
1535
+ question: string,
1536
+ legendQuery: string,
1537
+ resolvedEntities: LegendAIResolvedEntities,
1538
+ orchestratorOptions: Required<LegendAIOrchestratorOptionsParam>,
1539
+ metadata: LegendAIProductMetadata,
1540
+ context: LegendAIOperationContext,
1541
+ options: { startTime: number; modelContext?: LegendAIModelContext },
1542
+ ): Promise<boolean> {
1543
+ const { startTime, modelContext } = options;
1544
+ if (!modelContext) {
1545
+ return false;
1546
+ }
1547
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
1548
+ const { config, plugin, setMessages } = context;
1549
+
1550
+ const rootEntity = modelContext.entities.find(
1551
+ (entity) => entity.path === resolvedEntities.rootEntity,
1552
+ );
1553
+ if (!rootEntity) {
1554
+ return false;
1555
+ }
1556
+ const columns = extractFilteredColumns(legendQuery).filter((column) =>
1557
+ rootEntity.properties.some(
1558
+ (prop) => prop.name === column && !prop.type.includes('::'),
1559
+ ),
1560
+ );
1561
+ if (columns.length === 0) {
1562
+ return false;
1563
+ }
1564
+
1565
+ addThinkingStep(setMessages, 'Re-grounding with actual column values...');
1566
+ const probeResults = await Promise.all(
1567
+ columns.slice(0, MAX_RECOVERED_FILTER_COLUMNS).map(async (column) => {
1568
+ try {
1569
+ const probe = await withTimeout(
1570
+ plugin.executePureQuery(
1571
+ `${resolvedEntities.rootEntity}.all()->project([col(x|$x.${column}, '${column}')])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`,
1572
+ pureExecutionContext,
1573
+ dataProductCoordinates,
1574
+ config,
1575
+ ),
1576
+ ORCHESTRATOR_GENERATION_TIMEOUT_MS,
1577
+ );
1578
+ const values = (probe?.rows ?? [])
1579
+ .map((row) => row[column])
1580
+ .filter(isNonNullable)
1581
+ .map(String);
1582
+ return values.length > 0
1583
+ ? { column, values: Array.from(new Set(values)) }
1584
+ : undefined;
1585
+ } catch (probeError) {
1586
+ assertErrorThrown(probeError);
1587
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
1588
+ return undefined;
1589
+ }
1590
+ }),
1591
+ );
1592
+ const probed = new Map<string, string[]>();
1593
+ for (const entry of probeResults) {
1594
+ if (entry !== undefined) {
1595
+ probed.set(entry.column, entry.values);
1596
+ }
1597
+ }
1598
+ if (probed.size === 0) {
1599
+ return false;
1600
+ }
1601
+
1602
+ const base = buildEnrichedBusinessContext(
1603
+ question,
1604
+ resolvedEntities.rootEntity,
1605
+ resolvedEntities.relatedEntities,
1606
+ modelContext,
1607
+ );
1608
+ const augmentedContext = {
1609
+ ...base,
1610
+ businessContextMatch: {
1611
+ ...base.businessContextMatch,
1612
+ additionalNlModelContext: [
1613
+ ...(base.businessContextMatch?.additionalNlModelContext ?? []),
1614
+ ...buildProbedValueHints(probed),
1615
+ ],
1616
+ },
1617
+ };
1618
+ try {
1619
+ const response = await withTimeout(
1620
+ plugin.generateQueryViaOrchestrator(
1621
+ {
1622
+ user_question: question,
1623
+ semantic_search_resolution_details: {
1624
+ data_product_coordinates: dataProductCoordinates,
1625
+ root_entity: resolvedEntities.rootEntity,
1626
+ related_entities: resolvedEntities.relatedEntities,
1627
+ enriched_business_context: augmentedContext,
1628
+ },
1629
+ },
1630
+ config,
1631
+ ),
1632
+ ORCHESTRATOR_GENERATION_TIMEOUT_MS,
1633
+ );
1634
+ if (!response) {
1635
+ return false;
1636
+ }
1637
+ const safeQuery = ensurePureSafetyLimit(response.legend_query);
1638
+ updateLastAssistant(setMessages, () => ({
1639
+ sql: safeQuery,
1640
+ isExecuting: true,
1641
+ }));
1642
+ const result = await executePureQueryAndReport(
1643
+ safeQuery,
1644
+ pureExecutionContext,
1645
+ dataProductCoordinates,
1646
+ config,
1647
+ plugin,
1648
+ setMessages,
1649
+ startTime,
1650
+ );
1651
+ if (result && result.rows.length > 0) {
1652
+ await analyzeOrchestratorResults(
1653
+ question,
1654
+ safeQuery,
1655
+ result,
1656
+ metadata,
1657
+ context,
1658
+ startTime,
1659
+ );
1660
+ return true;
1661
+ }
1662
+ return false;
1663
+ } catch (regenerateError) {
1664
+ assertErrorThrown(regenerateError);
1665
+ addThinkingStep(
1666
+ setMessages,
1667
+ `Re-grounded retry failed: ${regenerateError.message.slice(
1668
+ 0,
1669
+ MAX_THINKING_ERROR_PREVIEW_LENGTH,
1670
+ )}`,
1671
+ );
1672
+ return false;
1673
+ }
1674
+ }
1675
+
1676
+ // Retries the query with case-insensitive (relaxed) string filters. Returns
1677
+ // true when the relaxed query returned rows and its results were reported.
1678
+ async function attemptRelaxedFilterRetry(
1679
+ question: string,
1680
+ legendQuery: string,
1681
+ orchestratorOptions: Required<LegendAIOrchestratorOptionsParam>,
1682
+ metadata: LegendAIProductMetadata,
1683
+ context: LegendAIOperationContext,
1684
+ startTime: number,
1685
+ ): Promise<boolean> {
1686
+ const { config, plugin, setMessages } = context;
1687
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
1688
+ const relaxedQuery = relaxExactStringFilters(legendQuery);
1689
+ if (relaxedQuery === legendQuery) {
1690
+ return false;
1691
+ }
1692
+ addThinkingStep(
1693
+ setMessages,
1694
+ 'Retrying with relaxed (case-insensitive) filters...',
1695
+ );
1696
+ try {
1697
+ const safeRelaxed = ensurePureSafetyLimit(relaxedQuery);
1698
+ updateLastAssistant(setMessages, () => ({
1699
+ sql: safeRelaxed,
1700
+ isExecuting: true,
1701
+ }));
1702
+ const relaxedResult = await executePureQueryAndReport(
1703
+ safeRelaxed,
1704
+ pureExecutionContext,
1705
+ dataProductCoordinates,
1706
+ config,
1707
+ plugin,
1708
+ setMessages,
1709
+ startTime,
1710
+ );
1711
+ if (relaxedResult && relaxedResult.rows.length > 0) {
1712
+ await analyzeOrchestratorResults(
1713
+ question,
1714
+ relaxedQuery,
1715
+ relaxedResult,
1716
+ metadata,
1717
+ context,
1718
+ startTime,
1719
+ );
1720
+ return true;
1721
+ }
1722
+ } catch (relaxError) {
1723
+ assertErrorThrown(relaxError);
1724
+ addThinkingStep(
1725
+ setMessages,
1726
+ `Relaxed-filter retry failed: ${relaxError.message.slice(
1727
+ 0,
1728
+ MAX_THINKING_ERROR_PREVIEW_LENGTH,
1729
+ )}`,
1730
+ );
1731
+ }
1732
+ return false;
1733
+ }
1734
+
1363
1735
  async function handleEmptyOrchestratorResults(
1364
1736
  question: string,
1365
1737
  legendQuery: string,
@@ -1373,7 +1745,35 @@ async function handleEmptyOrchestratorResults(
1373
1745
  },
1374
1746
  ): Promise<void> {
1375
1747
  const { startTime, modelContext } = options;
1376
- const { config, plugin, setMessages } = context;
1748
+ const { setMessages } = context;
1749
+
1750
+ if (
1751
+ await attemptRelaxedFilterRetry(
1752
+ question,
1753
+ legendQuery,
1754
+ orchestratorOptions,
1755
+ metadata,
1756
+ context,
1757
+ startTime,
1758
+ )
1759
+ ) {
1760
+ return;
1761
+ }
1762
+
1763
+ const regenerated = await attemptProbedRegeneration(
1764
+ question,
1765
+ legendQuery,
1766
+ resolvedEntities,
1767
+ orchestratorOptions,
1768
+ metadata,
1769
+ context,
1770
+ { startTime, ...(modelContext === undefined ? {} : { modelContext }) },
1771
+ );
1772
+ if (regenerated) {
1773
+ return;
1774
+ }
1775
+
1776
+ const triedRoots = new Set<string>([resolvedEntities.rootEntity]);
1377
1777
 
1378
1778
  if (resolvedEntities.relatedEntities.length > 0) {
1379
1779
  const alternateRoot = modelContext
@@ -1384,6 +1784,7 @@ async function handleEmptyOrchestratorResults(
1384
1784
  )
1385
1785
  : resolvedEntities.relatedEntities[0];
1386
1786
  if (alternateRoot) {
1787
+ triedRoots.add(alternateRoot);
1387
1788
  const succeeded = await retryWithAlternateRoot(
1388
1789
  question,
1389
1790
  alternateRoot,
@@ -1399,24 +1800,56 @@ async function handleEmptyOrchestratorResults(
1399
1800
  }
1400
1801
  }
1401
1802
 
1402
- addThinkingStep(
1803
+ addThinkingStep(setMessages, 'No results found for this question.');
1804
+ completeThinkingSteps(setMessages);
1805
+ updateLastAssistant(setMessages, () => ({
1806
+ textAnswer: buildUnanswerableMessage(),
1807
+ suggestedQueries: [],
1808
+ isProcessing: false,
1809
+ thinkingDuration: elapsedSeconds(startTime),
1810
+ }));
1811
+
1812
+ attachAlternateRootFallback(
1813
+ question,
1814
+ resolvedEntities,
1815
+ triedRoots,
1816
+ modelContext,
1403
1817
  setMessages,
1404
- 'No results returned \u2014 building contextual guidance...',
1405
1818
  );
1406
- updateLastAssistant(setMessages, () => ({ isProcessing: true }));
1407
- const fallback = await withTimeout(
1408
- plugin.buildNoResultsFallback(question, legendQuery, metadata, config),
1409
- ANALYSIS_TIMEOUT_MS,
1410
- );
1411
- if (fallback) {
1412
- completeThinkingSteps(setMessages);
1413
- updateLastAssistant(setMessages, () => ({
1414
- textAnswer: fallback.summary,
1415
- suggestedQueries: fallback.suggestedQueries,
1416
- isProcessing: false,
1417
- thinkingDuration: elapsedSeconds(startTime),
1418
- }));
1819
+ }
1820
+
1821
+ // Offers a "try a different root entity" fallback action when a not-yet-tried,
1822
+ // positively-ranked root exists for the question.
1823
+ function attachAlternateRootFallback(
1824
+ question: string,
1825
+ resolvedEntities: LegendAIResolvedEntities,
1826
+ triedRoots: Set<string>,
1827
+ modelContext: LegendAIModelContext | undefined,
1828
+ setMessages: MessageSetter,
1829
+ ): void {
1830
+ const nextRoot = modelContext
1831
+ ? rankEntities(question, modelContext).find(
1832
+ (ranked) => !triedRoots.has(ranked.entity.path) && ranked.score > 0,
1833
+ )?.entity
1834
+ : undefined;
1835
+ if (!nextRoot) {
1836
+ return;
1419
1837
  }
1838
+ const nextRelated = resolvedEntities.relatedEntities.filter(
1839
+ (path) => path !== nextRoot.path,
1840
+ );
1841
+ updateLastAssistant(setMessages, () => ({
1842
+ fallbackAction: {
1843
+ label: `Tried ${extractElementNameFromPath(
1844
+ resolvedEntities.rootEntity,
1845
+ )} as the root \u2014 try ${nextRoot.name} instead`,
1846
+ actionId: LEGEND_AI_ALTERNATE_ROOT_ACTION_ID,
1847
+ resolvedEntities: {
1848
+ rootEntity: nextRoot.path,
1849
+ relatedEntities: nextRelated,
1850
+ },
1851
+ },
1852
+ }));
1420
1853
  }
1421
1854
 
1422
1855
  async function resolveOrchestrationEntities(
@@ -1454,16 +1887,12 @@ function extractUserErrorFromMessage(errorMessage: string): string {
1454
1887
  const marker = 'with error:';
1455
1888
  const idx = errorMessage.toLowerCase().indexOf(marker);
1456
1889
  if (idx !== -1) {
1457
- const after = errorMessage.slice(idx + marker.length).trimStart();
1458
- const newlineIdx = after.indexOf('\n');
1459
- const reason = (
1460
- newlineIdx === -1 ? after : after.slice(0, newlineIdx)
1461
- ).trim();
1890
+ const reason = errorMessage.slice(idx + marker.length).trim();
1462
1891
  if (reason.length > 0) {
1463
- return reason.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1892
+ return reason.slice(0, MAX_DISPLAYED_ERROR_LENGTH);
1464
1893
  }
1465
1894
  }
1466
- return errorMessage.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1895
+ return errorMessage.slice(0, MAX_DISPLAYED_ERROR_LENGTH);
1467
1896
  }
1468
1897
 
1469
1898
  async function handleOrchestratorError(
@@ -1508,27 +1937,104 @@ async function handleOrchestratorError(
1508
1937
  `Fallback guidance failed: ${fallbackError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1509
1938
  );
1510
1939
  }
1511
- finishWithThinkingError(setMessages, error.message, startTime, orchErrorType);
1940
+ finishWithThinkingError(
1941
+ setMessages,
1942
+ extractUserErrorFromMessage(error.message),
1943
+ startTime,
1944
+ orchErrorType,
1945
+ );
1512
1946
  }
1513
1947
 
1514
- export async function processQuestionViaOrchestrator(
1515
- question: string,
1516
- dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
1517
- metadata: LegendAIProductMetadata,
1948
+ // Analyzes a non-empty orchestrator result (or runs empty-result recovery),
1949
+ // attaching the model-approach overview when available.
1950
+ async function reportOrchestratorExecutionResult(
1951
+ params: {
1952
+ question: string;
1953
+ safeQuery: string;
1954
+ execResult: LegendAISqlExecutionResultData;
1955
+ orchestratorOptions: Required<LegendAIOrchestratorOptionsParam>;
1956
+ metadata: LegendAIProductMetadata;
1957
+ resolvedEntities: LegendAIResolvedEntities;
1958
+ startTime: number;
1959
+ modelContext?: LegendAIModelContext;
1960
+ },
1518
1961
  context: LegendAIOperationContext,
1519
- pureExecutionContext?: QueryExplicitExecutionContextInfo,
1520
- preResolvedEntities?: LegendAIResolvedEntities,
1521
- modelContext?: LegendAIModelContext,
1522
1962
  ): Promise<void> {
1523
- const { config, plugin, setMessages } = context;
1524
- const startTime = Date.now();
1525
-
1963
+ const {
1964
+ question,
1965
+ safeQuery,
1966
+ execResult,
1967
+ orchestratorOptions,
1968
+ metadata,
1969
+ resolvedEntities,
1970
+ startTime,
1971
+ modelContext,
1972
+ } = params;
1973
+ const { setMessages } = context;
1526
1974
  try {
1527
- const resolvedEntities = await resolveOrchestrationEntities(
1528
- question,
1529
- dataProductCoordinates,
1530
- context,
1531
- pureExecutionContext,
1975
+ if (execResult.rows.length > 0) {
1976
+ await analyzeOrchestratorResults(
1977
+ question,
1978
+ safeQuery,
1979
+ execResult,
1980
+ metadata,
1981
+ context,
1982
+ startTime,
1983
+ );
1984
+ if (modelContext) {
1985
+ const approachText = buildDataQueryApproachText(
1986
+ question,
1987
+ modelContext,
1988
+ resolvedEntities.rootEntity,
1989
+ );
1990
+ if (approachText) {
1991
+ attachMetadataOverview(setMessages, approachText);
1992
+ }
1993
+ }
1994
+ } else {
1995
+ await handleEmptyOrchestratorResults(
1996
+ question,
1997
+ safeQuery,
1998
+ orchestratorOptions,
1999
+ metadata,
2000
+ resolvedEntities,
2001
+ context,
2002
+ { startTime, ...(modelContext === undefined ? {} : { modelContext }) },
2003
+ );
2004
+ }
2005
+ } catch (analysisError) {
2006
+ assertErrorThrown(analysisError);
2007
+ addThinkingStep(
2008
+ setMessages,
2009
+ `Result analysis failed: ${analysisError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2010
+ );
2011
+ } finally {
2012
+ completeThinkingSteps(setMessages);
2013
+ updateLastAssistant(setMessages, () => ({
2014
+ isProcessing: false,
2015
+ thinkingDuration: elapsedSeconds(startTime),
2016
+ }));
2017
+ }
2018
+ }
2019
+
2020
+ export async function processQuestionViaOrchestrator(
2021
+ question: string,
2022
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
2023
+ metadata: LegendAIProductMetadata,
2024
+ context: LegendAIOperationContext,
2025
+ pureExecutionContext?: QueryExplicitExecutionContextInfo,
2026
+ preResolvedEntities?: LegendAIResolvedEntities,
2027
+ modelContext?: LegendAIModelContext,
2028
+ ): Promise<void> {
2029
+ const { config, plugin, setMessages } = context;
2030
+ const startTime = Date.now();
2031
+
2032
+ try {
2033
+ const resolvedEntities = await resolveOrchestrationEntities(
2034
+ question,
2035
+ dataProductCoordinates,
2036
+ context,
2037
+ pureExecutionContext,
1532
2038
  preResolvedEntities,
1533
2039
  modelContext,
1534
2040
  );
@@ -1608,43 +2114,19 @@ export async function processQuestionViaOrchestrator(
1608
2114
  return;
1609
2115
  }
1610
2116
 
1611
- try {
1612
- if (execResult.rows.length > 0) {
1613
- await analyzeOrchestratorResults(
1614
- question,
1615
- safeQuery,
1616
- execResult,
1617
- metadata,
1618
- context,
1619
- startTime,
1620
- );
1621
- } else {
1622
- await handleEmptyOrchestratorResults(
1623
- question,
1624
- safeQuery,
1625
- { dataProductCoordinates, pureExecutionContext },
1626
- metadata,
1627
- resolvedEntities,
1628
- context,
1629
- {
1630
- startTime,
1631
- ...(modelContext === undefined ? {} : { modelContext }),
1632
- },
1633
- );
1634
- }
1635
- } catch (analysisError) {
1636
- assertErrorThrown(analysisError);
1637
- addThinkingStep(
1638
- setMessages,
1639
- `Result analysis failed: ${analysisError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
1640
- );
1641
- } finally {
1642
- completeThinkingSteps(setMessages);
1643
- updateLastAssistant(setMessages, () => ({
1644
- isProcessing: false,
1645
- thinkingDuration: elapsedSeconds(startTime),
1646
- }));
1647
- }
2117
+ await reportOrchestratorExecutionResult(
2118
+ {
2119
+ question,
2120
+ safeQuery,
2121
+ execResult,
2122
+ orchestratorOptions: { dataProductCoordinates, pureExecutionContext },
2123
+ metadata,
2124
+ resolvedEntities,
2125
+ startTime,
2126
+ ...(modelContext === undefined ? {} : { modelContext }),
2127
+ },
2128
+ context,
2129
+ );
1648
2130
  } catch (error) {
1649
2131
  assertErrorThrown(error);
1650
2132
  await handleOrchestratorError(
@@ -1666,6 +2148,10 @@ export function cleanLlmSqlResponse(raw: string): string {
1666
2148
  .trim();
1667
2149
  }
1668
2150
 
2151
+ function normalizeSqlForComparison(sql: string): string {
2152
+ return sql.replaceAll(/\s+/gu, ' ').trim().toLowerCase();
2153
+ }
2154
+
1669
2155
  export function isValidSqlCorrection(
1670
2156
  trimmed: string,
1671
2157
  currentSql: string,
@@ -1677,139 +2163,6 @@ export function isValidSqlCorrection(
1677
2163
  );
1678
2164
  }
1679
2165
 
1680
- export function sanitizeJoinOrderBy(sql: string): string {
1681
- if (!JOIN_PATTERN.test(sql)) {
1682
- return sql;
1683
- }
1684
- const parts = sql.split(ORDER_BY_SPLIT);
1685
- if (parts.length < 2) {
1686
- return sql;
1687
- }
1688
-
1689
- const beforeOrderBy = parts[0] ?? '';
1690
- const afterOrderBy = parts.slice(1).join('ORDER BY').replace(/^\s+/, '');
1691
-
1692
- const selectAliases = new Map<string, string>();
1693
- for (const m of beforeOrderBy.matchAll(SELECT_ALIAS_PATTERN)) {
1694
- const tableAlias = (m.groups?.tbl ?? '').toLowerCase();
1695
- const colName = (m.groups?.col ?? '').toLowerCase();
1696
- const asAlias = m.groups?.qAlias ?? m.groups?.uAlias ?? '';
1697
- selectAliases.set(`${tableAlias}.${colName}`, asAlias);
1698
- }
1699
-
1700
- if (selectAliases.size === 0) {
1701
- return sql;
1702
- }
1703
-
1704
- // Build a lookup of the original alias.col text → its SELECT alias replacement.
1705
- // matchAll exposes typed `.groups`, so no cast is needed.
1706
- const replacements = new Map<string, string>();
1707
- for (const m of afterOrderBy.matchAll(ALIAS_DOT_COL_PATTERN)) {
1708
- const tbl = (m.groups?.tbl ?? '').toLowerCase();
1709
- const col = (m.groups?.col ?? '').toLowerCase();
1710
- const alias = selectAliases.get(`${tbl}.${col}`);
1711
- if (alias) {
1712
- replacements.set(m[0], `"${alias}"`);
1713
- }
1714
- }
1715
-
1716
- if (replacements.size === 0) {
1717
- return sql;
1718
- }
1719
-
1720
- const rewritten = afterOrderBy.replaceAll(
1721
- ALIAS_DOT_COL_PATTERN,
1722
- (match) => replacements.get(match) ?? match,
1723
- );
1724
-
1725
- if (rewritten === afterOrderBy) {
1726
- return sql;
1727
- }
1728
- return `${beforeOrderBy}ORDER BY ${rewritten}`;
1729
- }
1730
-
1731
- export function sanitizeJoinSameKeyColumns(
1732
- sql: string,
1733
- services?: TDSServiceSchema[],
1734
- ): string {
1735
- if (!JOIN_PATTERN.test(sql)) {
1736
- return sql;
1737
- }
1738
-
1739
- const joinRegex =
1740
- /\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;
1741
-
1742
- let result = sql;
1743
- let match: RegExpExecArray | null;
1744
-
1745
- while ((match = joinRegex.exec(sql)) !== null) {
1746
- const pId = match.groups?.pId ?? '';
1747
- const rightAlias = match.groups?.rAlias ?? '';
1748
- const onClause = (match.groups?.onClause ?? '').slice(0, 500);
1749
-
1750
- const sameKeyMatch =
1751
- /(?<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(
1752
- onClause,
1753
- );
1754
- if (!sameKeyMatch) {
1755
- continue;
1756
- }
1757
-
1758
- const leftCol = sameKeyMatch.groups?.lCol ?? '';
1759
- const rightCol = sameKeyMatch.groups?.rCol ?? '';
1760
- const matchedRightAlias = sameKeyMatch.groups?.rAl ?? '';
1761
-
1762
- if (
1763
- leftCol.toLowerCase() !== rightCol.toLowerCase() ||
1764
- matchedRightAlias.toLowerCase() !== rightAlias.toLowerCase()
1765
- ) {
1766
- continue;
1767
- }
1768
-
1769
- const renamedKey = `${rightAlias}_${rightCol}`;
1770
- const columnList = buildSubqueryColumnList(
1771
- pId,
1772
- rightCol,
1773
- renamedKey,
1774
- services,
1775
- );
1776
- const originalFragment = `p('${pId}') AS ${rightAlias}`;
1777
- const subqueryFragment = `(SELECT ${columnList} FROM p('${pId}')) AS ${rightAlias}`;
1778
-
1779
- const oldOnRef = `${rightAlias}."${rightCol}"`;
1780
- const newOnRef = `${rightAlias}."${renamedKey}"`;
1781
-
1782
- result = result.replace(originalFragment, subqueryFragment);
1783
- result = result.replaceAll(oldOnRef, newOnRef);
1784
- }
1785
-
1786
- return result;
1787
- }
1788
-
1789
- function buildSubqueryColumnList(
1790
- pId: string,
1791
- originalKey: string,
1792
- renamedKey: string,
1793
- services?: TDSServiceSchema[],
1794
- ): string {
1795
- const svc = services?.find(
1796
- (s) =>
1797
- s.dataProductPath &&
1798
- s.pattern &&
1799
- pId === `${s.dataProductPath}.${s.pattern.replace(/^\//, '')}`,
1800
- );
1801
- if (!svc || svc.columns.length === 0) {
1802
- return `"${originalKey}" AS "${renamedKey}", *`;
1803
- }
1804
- return svc.columns
1805
- .map((c) =>
1806
- c.name.toLowerCase() === originalKey.toLowerCase()
1807
- ? `"${c.name}" AS "${renamedKey}"`
1808
- : `"${c.name}"`,
1809
- )
1810
- .join(', ');
1811
- }
1812
-
1813
2166
  export function sanitizeLiteralColumns(sql: string): string {
1814
2167
  if (!UNION_ALL_PATTERN.test(sql)) {
1815
2168
  return sql;
@@ -2111,14 +2464,15 @@ export function ensureSafeLimit(
2111
2464
  }
2112
2465
 
2113
2466
  function prepareSafeSql(sql: string, services: TDSServiceSchema[]): string {
2114
- const safeSql = ensureSafeLimit(
2115
- ensureDateParameters(
2116
- sanitizeLiteralColumns(
2117
- sanitizeJoinSameKeyColumns(sanitizeJoinOrderBy(sql), services),
2118
- ),
2467
+ const joined = sanitizeLiteralColumns(
2468
+ sanitizeJoinSameKeyColumns(
2469
+ sanitizeJoinOrderBy(sanitizeJoinDuplicateColumns(sql, services)),
2119
2470
  services,
2120
2471
  ),
2121
2472
  );
2473
+ const wrapped = wrapBareJoinAccessPoints(joined, services);
2474
+ const bounded = boundCrossAccessPointJoinDrivingSide(wrapped);
2475
+ const safeSql = ensureSafeLimit(ensureDateParameters(bounded, services));
2122
2476
  const unsupported = detectUnsupportedEnginePattern(safeSql);
2123
2477
  if (unsupported) {
2124
2478
  throw new LegendAIUnsupportedEngineShapeError(unsupported.hint);
@@ -2147,7 +2501,14 @@ async function executeSqlForServices(
2147
2501
  (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
2148
2502
  );
2149
2503
  if (isAccessPoint && dataProductCoordinates) {
2150
- return plugin.executeLakehouseSql(safeSql, dataProductCoordinates, config);
2504
+ const result = await withTimeout(
2505
+ plugin.executeLakehouseSql(safeSql, dataProductCoordinates, config),
2506
+ AP_SQL_EXECUTION_TIMEOUT_MS,
2507
+ );
2508
+ if (result === undefined) {
2509
+ throw new LegendAIExecutionTimeoutError(AP_SQL_EXECUTION_TIMEOUT_MESSAGE);
2510
+ }
2511
+ return result;
2151
2512
  }
2152
2513
  return plugin.executeSql(safeSql, config);
2153
2514
  }
@@ -2183,6 +2544,9 @@ async function executeSqlWithRetries(
2183
2544
  return { sql: currentSql, result };
2184
2545
  } catch (executeError) {
2185
2546
  assertErrorThrown(executeError);
2547
+ if (executeError instanceof LegendAIExecutionTimeoutError) {
2548
+ return { sql: currentSql, error: executeError.message };
2549
+ }
2186
2550
  if (attempt >= DEFAULT_MAX_EXECUTION_RETRIES) {
2187
2551
  return { sql: currentSql, error: executeError.message };
2188
2552
  }
@@ -2353,7 +2717,11 @@ function buildZeroRowMessage(services: TDSServiceSchema[]): string {
2353
2717
  return `The SQL query executed successfully but returned **0 rows**. Note: parameter detection for ${names} was incomplete — this service may require additional parameters not shown in the schema. Try specifying filter values (dates, IDs, etc.) directly in your question.`;
2354
2718
  }
2355
2719
 
2356
- 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.';
2720
+ return buildUnanswerableMessage();
2721
+ }
2722
+
2723
+ function buildUnanswerableMessage(): string {
2724
+ return `Sorry — I couldn't find an answer to this one. This assistant is still learning this data product, so it may not handle every question yet. ${LEGEND_AI_FEEDBACK_PROMPT} Use the 👍 / 👎 below — your feedback helps us improve. You can also try rephrasing with a specific column or value.`;
2357
2725
  }
2358
2726
 
2359
2727
  function handleSqlGenerationFailure(
@@ -2393,6 +2761,7 @@ interface QueryResultReport {
2393
2761
  sqlResult: LegendAISqlExecutionResultData;
2394
2762
  question: string;
2395
2763
  services: TDSServiceSchema[];
2764
+ allAccessPoints?: TDSServiceSchema[];
2396
2765
  }
2397
2766
 
2398
2767
  async function reportQueryResults(
@@ -2402,8 +2771,8 @@ async function reportQueryResults(
2402
2771
  startTime: number,
2403
2772
  hasOrchestratorFallback: boolean,
2404
2773
  ): Promise<void> {
2405
- const { currentSql, sqlResult, question, services } = report;
2406
- const { setMessages } = context;
2774
+ const { currentSql, sqlResult, question, services, allAccessPoints } = report;
2775
+ const { config, plugin, setMessages } = context;
2407
2776
  if (sqlResult.rows.length > 0) {
2408
2777
  const columns = deduplicateColumns(sqlResult.columns);
2409
2778
  const rows = sqlResult.rows;
@@ -2452,7 +2821,34 @@ async function reportQueryResults(
2452
2821
  setMessages,
2453
2822
  'Query returned 0 rows after correction attempts.',
2454
2823
  );
2824
+ const joinExplanation = buildCrossJoinZeroRowExplanation(
2825
+ currentSql,
2826
+ services,
2827
+ );
2828
+ const joinSuggestions =
2829
+ joinExplanation === undefined
2830
+ ? []
2831
+ : buildJoinablePairSuggestions(services, allAccessPoints ?? services);
2832
+ const llmAnalysis = await withTimeout(
2833
+ plugin
2834
+ .buildNoResultsFallback(
2835
+ question,
2836
+ currentSql,
2837
+ metadata,
2838
+ config,
2839
+ joinExplanation,
2840
+ )
2841
+ .catch((error: unknown) => {
2842
+ assertErrorThrown(error);
2843
+ return undefined;
2844
+ }),
2845
+ ANALYSIS_TIMEOUT_MS,
2846
+ );
2455
2847
  completeThinkingSteps(setMessages);
2848
+ const suggestions =
2849
+ joinSuggestions.length > 0
2850
+ ? joinSuggestions
2851
+ : (llmAnalysis?.suggestedQueries ?? []);
2456
2852
  const fallback = hasOrchestratorFallback
2457
2853
  ? {
2458
2854
  fallbackAction: {
@@ -2462,7 +2858,11 @@ async function reportQueryResults(
2462
2858
  }
2463
2859
  : {};
2464
2860
  updateLastAssistant(setMessages, () => ({
2465
- textAnswer: buildZeroRowMessage(services),
2861
+ textAnswer:
2862
+ llmAnalysis?.summary ??
2863
+ joinExplanation ??
2864
+ buildZeroRowMessage(services),
2865
+ ...(suggestions.length > 0 ? { suggestedQueries: suggestions } : {}),
2466
2866
  ...fallback,
2467
2867
  isProcessing: false,
2468
2868
  isExecuting: false,
@@ -2471,11 +2871,13 @@ async function reportQueryResults(
2471
2871
  }
2472
2872
  }
2473
2873
 
2874
+ const DEFAULT_MAX_SUPPLEMENTED_SERVICES = 4;
2875
+
2474
2876
  export function supplementMissingCoverage(
2475
2877
  question: string,
2476
2878
  selected: TDSServiceSchema[],
2477
2879
  allServices: TDSServiceSchema[],
2478
- maxTotal: number = 4,
2880
+ maxTotal: number = DEFAULT_MAX_SUPPLEMENTED_SERVICES,
2479
2881
  ): TDSServiceSchema[] {
2480
2882
  if (selected.length >= maxTotal || allServices.length <= selected.length) {
2481
2883
  return selected;
@@ -2692,6 +3094,403 @@ async function selectBestServices(
2692
3094
  }
2693
3095
  }
2694
3096
 
3097
+ const EQUALITY_PREDICATE_PATTERN =
3098
+ /"(?<col>[^"]+)"\s*=\s*'(?<val>[^']*(?:''[^']*)*)'/gu;
3099
+ export const IN_PREDICATE_PATTERN =
3100
+ /"(?<col>[^"]+)"\s+in\s*\((?<list>[^)]+)\)/giu;
3101
+ export const IN_LIST_LITERAL_PATTERN = /'(?<val>[^']*(?:''[^']*)*)'/gu;
3102
+ const MIN_FUZZY_LITERAL_LENGTH = 3;
3103
+
3104
+ function normalizeFilterLiteral(value: string): string {
3105
+ return value.replaceAll(/[^a-z0-9]/giu, '').toUpperCase();
3106
+ }
3107
+
3108
+ function escapeSqlStringLiteral(value: string): string {
3109
+ return value.replaceAll("'", "''");
3110
+ }
3111
+
3112
+ export function extractFromClause(sql: string): string | undefined {
3113
+ const fromMatch = /\bfrom\b/iu.exec(sql);
3114
+ if (!fromMatch) {
3115
+ return undefined;
3116
+ }
3117
+ const rest = sql.slice(fromMatch.index);
3118
+ const afterFrom = rest.slice('from'.length);
3119
+ const boundary = /\b(?:where|group\s+by|order\s+by|having|limit)\b/iu.exec(
3120
+ afterFrom,
3121
+ );
3122
+ return (
3123
+ boundary ? rest.slice(0, 'from'.length + boundary.index) : rest
3124
+ ).trim();
3125
+ }
3126
+
3127
+ // Extracts the WHERE clause segment (from the first `WHERE` up to the next
3128
+ // top-level clause boundary), so filter self-healing only rewrites WHERE
3129
+ // predicates — never JOIN ON keys or HAVING literals.
3130
+ export function extractWhereClause(sql: string): string | undefined {
3131
+ const whereMatch = /\bwhere\b/iu.exec(sql);
3132
+ if (!whereMatch) {
3133
+ return undefined;
3134
+ }
3135
+ const rest = sql.slice(whereMatch.index);
3136
+ const afterWhere = rest.slice('where'.length);
3137
+ const boundary =
3138
+ /\b(?:group\s+by|order\s+by|having|limit|window|qualify)\b/iu.exec(
3139
+ afterWhere,
3140
+ );
3141
+ return (
3142
+ boundary ? rest.slice(0, 'where'.length + boundary.index) : rest
3143
+ ).trim();
3144
+ }
3145
+
3146
+ export function resolveFilterLiteral(
3147
+ literal: string,
3148
+ values: string[],
3149
+ ): string | undefined {
3150
+ if (values.includes(literal)) {
3151
+ return undefined;
3152
+ }
3153
+ const caseInsensitive = Array.from(
3154
+ new Set(values.filter((v) => v.toLowerCase() === literal.toLowerCase())),
3155
+ );
3156
+ if (caseInsensitive.length === 1) {
3157
+ return caseInsensitive[0];
3158
+ }
3159
+ const normalizedLiteral = normalizeFilterLiteral(literal);
3160
+ if (normalizedLiteral.length < MIN_FUZZY_LITERAL_LENGTH) {
3161
+ return undefined;
3162
+ }
3163
+ const normalizedExact = Array.from(
3164
+ new Set(
3165
+ values.filter((v) => normalizeFilterLiteral(v) === normalizedLiteral),
3166
+ ),
3167
+ );
3168
+ if (normalizedExact.length === 1) {
3169
+ return normalizedExact[0];
3170
+ }
3171
+ const prefixMatches = Array.from(
3172
+ new Set(
3173
+ values.filter((v) => {
3174
+ const normalizedValue = normalizeFilterLiteral(v);
3175
+ return (
3176
+ normalizedValue.length >= MIN_FUZZY_LITERAL_LENGTH &&
3177
+ (normalizedValue.startsWith(normalizedLiteral) ||
3178
+ normalizedLiteral.startsWith(normalizedValue))
3179
+ );
3180
+ }),
3181
+ ),
3182
+ );
3183
+ return prefixMatches.length === 1 ? prefixMatches[0] : undefined;
3184
+ }
3185
+
3186
+ type FilterValueProbe = (column: string) => Promise<string[] | undefined>;
3187
+
3188
+ // Rewrites `"col" = 'literal'` predicates in the WHERE clause whose literal
3189
+ // fuzzy-matches a real probed column value, returning the updated SQL and
3190
+ // whether anything changed. Every distinct predicate is corrected (a column can
3191
+ // appear with several literals across `OR` branches), and only the WHERE clause
3192
+ // is scanned so JOIN ON / HAVING literals are left untouched.
3193
+ async function correctEqualityFilters(
3194
+ sql: string,
3195
+ probeColumnValues: FilterValueProbe,
3196
+ setMessages: MessageSetter,
3197
+ ): Promise<{ sql: string; corrected: boolean }> {
3198
+ const whereClause = extractWhereClause(sql);
3199
+ if (whereClause === undefined) {
3200
+ return { sql, corrected: false };
3201
+ }
3202
+ const literalsByColumn = new Map<string, Set<string>>();
3203
+ for (const match of whereClause.matchAll(EQUALITY_PREDICATE_PATTERN)) {
3204
+ const column = match.groups?.col;
3205
+ const literal = match.groups?.val;
3206
+ if (column === undefined || literal === undefined) {
3207
+ continue;
3208
+ }
3209
+ const literals = literalsByColumn.get(column) ?? new Set<string>();
3210
+ literals.add(literal);
3211
+ literalsByColumn.set(column, literals);
3212
+ }
3213
+ const resolutions = new Map<string, Map<string, string>>();
3214
+ for (const [column, literals] of literalsByColumn) {
3215
+ const values = await probeColumnValues(column);
3216
+ if (values === undefined) {
3217
+ continue;
3218
+ }
3219
+ for (const literal of literals) {
3220
+ const resolved = resolveFilterLiteral(literal, values);
3221
+ if (resolved !== undefined && resolved !== literal) {
3222
+ const columnResolutions = resolutions.get(column) ?? new Map();
3223
+ columnResolutions.set(literal, resolved);
3224
+ resolutions.set(column, columnResolutions);
3225
+ addThinkingStep(
3226
+ setMessages,
3227
+ `Matched "${column}" = '${literal}' to '${resolved}' from the column's values...`,
3228
+ );
3229
+ }
3230
+ }
3231
+ }
3232
+ if (resolutions.size === 0) {
3233
+ return { sql, corrected: false };
3234
+ }
3235
+ const correctedWhere = whereClause.replace(
3236
+ EQUALITY_PREDICATE_PATTERN,
3237
+ (matched: string, column: string, literal: string) => {
3238
+ const resolved = resolutions.get(column)?.get(literal);
3239
+ return resolved === undefined
3240
+ ? matched
3241
+ : matched.replace(
3242
+ `'${literal}'`,
3243
+ () => `'${escapeSqlStringLiteral(resolved)}'`,
3244
+ );
3245
+ },
3246
+ );
3247
+ return {
3248
+ sql: sql.replace(whereClause, () => correctedWhere),
3249
+ corrected: true,
3250
+ };
3251
+ }
3252
+
3253
+ // Rewrites the literals of a single `IN (...)` list to their fuzzy-matched
3254
+ // column values; returns the joined list text and whether anything changed.
3255
+ function rewriteInListLiterals(
3256
+ list: string,
3257
+ column: string,
3258
+ values: string[],
3259
+ setMessages: MessageSetter,
3260
+ ): { rewritten: string; changed: boolean } {
3261
+ let changed = false;
3262
+ const rewrittenList: string[] = [];
3263
+ for (const literalMatch of list.matchAll(IN_LIST_LITERAL_PATTERN)) {
3264
+ const literal = literalMatch.groups?.val;
3265
+ if (literal === undefined) {
3266
+ continue;
3267
+ }
3268
+ const resolved = resolveFilterLiteral(literal, values);
3269
+ if (resolved !== undefined && resolved !== literal) {
3270
+ rewrittenList.push(`'${escapeSqlStringLiteral(resolved)}'`);
3271
+ changed = true;
3272
+ addThinkingStep(
3273
+ setMessages,
3274
+ `Matched "${column}" IN '${literal}' to '${resolved}' from the column's values...`,
3275
+ );
3276
+ } else {
3277
+ rewrittenList.push(`'${literal}'`);
3278
+ }
3279
+ }
3280
+ return { rewritten: rewrittenList.join(', '), changed };
3281
+ }
3282
+
3283
+ async function correctInPredicateFilters(
3284
+ sql: string,
3285
+ probeColumnValues: FilterValueProbe,
3286
+ setMessages: MessageSetter,
3287
+ ): Promise<{ sql: string; corrected: boolean }> {
3288
+ const whereClause = extractWhereClause(sql);
3289
+ if (whereClause === undefined) {
3290
+ return { sql, corrected: false };
3291
+ }
3292
+ const columns = new Set<string>();
3293
+ for (const match of whereClause.matchAll(IN_PREDICATE_PATTERN)) {
3294
+ const column = match.groups?.col;
3295
+ if (column !== undefined) {
3296
+ columns.add(column);
3297
+ }
3298
+ }
3299
+ const valuesByColumn = new Map<string, string[]>();
3300
+ for (const column of columns) {
3301
+ const values = await probeColumnValues(column);
3302
+ if (values !== undefined) {
3303
+ valuesByColumn.set(column, values);
3304
+ }
3305
+ }
3306
+ if (valuesByColumn.size === 0) {
3307
+ return { sql, corrected: false };
3308
+ }
3309
+ const correctedWhere = whereClause.replace(
3310
+ IN_PREDICATE_PATTERN,
3311
+ (matched: string, column: string, list: string) => {
3312
+ const values = valuesByColumn.get(column);
3313
+ if (values === undefined) {
3314
+ return matched;
3315
+ }
3316
+ const { rewritten, changed } = rewriteInListLiterals(
3317
+ list,
3318
+ column,
3319
+ values,
3320
+ setMessages,
3321
+ );
3322
+ return changed && rewritten.length > 0
3323
+ ? matched.replace(list, () => rewritten)
3324
+ : matched;
3325
+ },
3326
+ );
3327
+ return correctedWhere === whereClause
3328
+ ? { sql, corrected: false }
3329
+ : { sql: sql.replace(whereClause, () => correctedWhere), corrected: true };
3330
+ }
3331
+
3332
+ // Distinct columns filtered by equality or IN predicates in the WHERE clause,
3333
+ // in encounter order — the set the zero-row recovery probes for real values.
3334
+ function collectWhereFilterColumns(sql: string): string[] {
3335
+ const whereClause = extractWhereClause(sql);
3336
+ if (whereClause === undefined) {
3337
+ return [];
3338
+ }
3339
+ const columns: string[] = [];
3340
+ const seen = new Set<string>();
3341
+ const addColumn = (column: string | undefined): void => {
3342
+ if (column !== undefined && !seen.has(column)) {
3343
+ seen.add(column);
3344
+ columns.push(column);
3345
+ }
3346
+ };
3347
+ for (const match of whereClause.matchAll(EQUALITY_PREDICATE_PATTERN)) {
3348
+ addColumn(match.groups?.col);
3349
+ }
3350
+ for (const match of whereClause.matchAll(IN_PREDICATE_PATTERN)) {
3351
+ addColumn(match.groups?.col);
3352
+ }
3353
+ return columns;
3354
+ }
3355
+
3356
+ // Indexes each access point by its lowercased column names (first writer wins),
3357
+ // so a filter column resolves to the access point that declares it.
3358
+ function buildAccessPointColumnIndex(
3359
+ services: TDSServiceSchema[],
3360
+ ): Map<string, TDSServiceSchema> {
3361
+ const index = new Map<string, TDSServiceSchema>();
3362
+ for (const accessPoint of services) {
3363
+ if (accessPoint.sourceType !== TDSServiceSourceType.ACCESS_POINT) {
3364
+ continue;
3365
+ }
3366
+ for (const column of accessPoint.columns) {
3367
+ const key = column.name.toLowerCase();
3368
+ if (!index.has(key)) {
3369
+ index.set(key, accessPoint);
3370
+ }
3371
+ }
3372
+ }
3373
+ return index;
3374
+ }
3375
+
3376
+ // Recovers a zero-row query by re-probing each WHERE-filter column's real distinct
3377
+ // values and rewriting equality/IN literals that don't match (casing/spelling).
3378
+ async function attemptDistinctValueRecovery(
3379
+ currentSql: string,
3380
+ selectedServices: TDSServiceSchema[],
3381
+ dataProductCoordinates:
3382
+ | LegendAIOrchestratorDataProductCoordinates
3383
+ | undefined,
3384
+ context: LegendAIOperationContext,
3385
+ ): Promise<
3386
+ { sql: string; result: LegendAISqlExecutionResultData } | undefined
3387
+ > {
3388
+ const { plugin, config, setMessages } = context;
3389
+ const isAccessPointProbe =
3390
+ dataProductCoordinates !== undefined &&
3391
+ selectedServices.some(
3392
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
3393
+ );
3394
+ const accessPointByColumn = isAccessPointProbe
3395
+ ? buildAccessPointColumnIndex(selectedServices)
3396
+ : new Map<string, TDSServiceSchema>();
3397
+ const fromClause = isAccessPointProbe
3398
+ ? undefined
3399
+ : extractFromClause(currentSql);
3400
+ if (!isAccessPointProbe && fromClause === undefined) {
3401
+ return undefined;
3402
+ }
3403
+
3404
+ const probeAccessPointColumn = async (
3405
+ column: string,
3406
+ ): Promise<LegendAISqlExecutionResultData> => {
3407
+ const accessPoint = accessPointByColumn.get(column.toLowerCase());
3408
+ const pId = accessPoint ? servicePId(accessPoint) : undefined;
3409
+ if (pId === undefined) {
3410
+ return { columns: [], rows: [] };
3411
+ }
3412
+ const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`;
3413
+ return plugin.executeLakehouseRelationQuery(
3414
+ relationQuery,
3415
+ guaranteeNonNullable(dataProductCoordinates),
3416
+ config,
3417
+ );
3418
+ };
3419
+
3420
+ const probedValues = new Map<string, string[] | undefined>();
3421
+ const probeColumnValues = async (
3422
+ column: string,
3423
+ ): Promise<string[] | undefined> => {
3424
+ if (probedValues.has(column)) {
3425
+ return probedValues.get(column);
3426
+ }
3427
+ if (probedValues.size >= MAX_RECOVERED_FILTER_COLUMNS) {
3428
+ return undefined;
3429
+ }
3430
+ let values: string[] | undefined;
3431
+ try {
3432
+ const probe = isAccessPointProbe
3433
+ ? await probeAccessPointColumn(column)
3434
+ : await executeSqlForServices(
3435
+ `SELECT DISTINCT "${column}" ${fromClause} LIMIT ${DISTINCT_PROBE_ROW_LIMIT}`,
3436
+ selectedServices,
3437
+ dataProductCoordinates,
3438
+ plugin,
3439
+ config,
3440
+ );
3441
+ values = probe.rows
3442
+ .map((row) => row[column])
3443
+ .filter(isNonNullable)
3444
+ .map(String);
3445
+ } catch (probeError) {
3446
+ assertErrorThrown(probeError);
3447
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
3448
+ values = undefined;
3449
+ }
3450
+ probedValues.set(column, values);
3451
+ return values;
3452
+ };
3453
+
3454
+ await Promise.all(
3455
+ collectWhereFilterColumns(currentSql)
3456
+ .slice(0, MAX_RECOVERED_FILTER_COLUMNS)
3457
+ .map((column) => probeColumnValues(column)),
3458
+ );
3459
+
3460
+ const equalityPass = await correctEqualityFilters(
3461
+ currentSql,
3462
+ probeColumnValues,
3463
+ setMessages,
3464
+ );
3465
+ const inPass = await correctInPredicateFilters(
3466
+ equalityPass.sql,
3467
+ probeColumnValues,
3468
+ setMessages,
3469
+ );
3470
+ const correctedSql = inPass.sql;
3471
+ const corrected = equalityPass.corrected || inPass.corrected;
3472
+
3473
+ if (!corrected) {
3474
+ return undefined;
3475
+ }
3476
+ try {
3477
+ const retryResult = await executeSqlForServices(
3478
+ correctedSql,
3479
+ selectedServices,
3480
+ dataProductCoordinates,
3481
+ plugin,
3482
+ config,
3483
+ );
3484
+ if (retryResult.rows.length > 0) {
3485
+ return { sql: correctedSql, result: retryResult };
3486
+ }
3487
+ } catch (retryError) {
3488
+ assertErrorThrown(retryError);
3489
+ addThinkingStep(setMessages, 'Filter-recovery retry failed');
3490
+ }
3491
+ return undefined;
3492
+ }
3493
+
2695
3494
  async function tryRecoverZeroRows(
2696
3495
  currentSql: string,
2697
3496
  sqlResult: LegendAISqlExecutionResultData,
@@ -2702,10 +3501,15 @@ async function tryRecoverZeroRows(
2702
3501
  | LegendAIOrchestratorDataProductCoordinates
2703
3502
  | undefined,
2704
3503
  context: LegendAIOperationContext,
2705
- ): Promise<{ sql: string; result: LegendAISqlExecutionResultData }> {
3504
+ ): Promise<{
3505
+ sql: string;
3506
+ result: LegendAISqlExecutionResultData;
3507
+ selfHealed: LegendAISelfHealKind;
3508
+ }> {
2706
3509
  const { plugin, config, setMessages } = context;
2707
3510
  let recoveredSql = currentSql;
2708
3511
  let recoveredResult = sqlResult;
3512
+ let selfHealed = LegendAISelfHealKind.NONE;
2709
3513
 
2710
3514
  const strippedSql = stripGuessedNonDateServiceParams(recoveredSql, question);
2711
3515
  if (strippedSql !== recoveredSql) {
@@ -2735,6 +3539,25 @@ async function tryRecoverZeroRows(
2735
3539
  }
2736
3540
  }
2737
3541
 
3542
+ if (recoveredResult.rows.length === 0) {
3543
+ addThinkingStep(
3544
+ setMessages,
3545
+ "Checking filter values against the column's actual values...",
3546
+ );
3547
+ const resolved = await attemptDistinctValueRecovery(
3548
+ recoveredSql,
3549
+ selectedServices,
3550
+ dataProductCoordinates,
3551
+ context,
3552
+ );
3553
+ if (resolved) {
3554
+ recoveredSql = resolved.sql;
3555
+ recoveredResult = resolved.result;
3556
+ selfHealed = LegendAISelfHealKind.DISTINCT_VALUE;
3557
+ updateLastAssistant(setMessages, () => ({ sql: resolved.sql }));
3558
+ }
3559
+ }
3560
+
2738
3561
  if (recoveredResult.rows.length === 0) {
2739
3562
  const correction = await attemptZeroRowCorrection(
2740
3563
  recoveredSql,
@@ -2747,10 +3570,11 @@ async function tryRecoverZeroRows(
2747
3570
  if (correction) {
2748
3571
  recoveredSql = correction.sql;
2749
3572
  recoveredResult = correction.result;
3573
+ selfHealed = LegendAISelfHealKind.LLM_ZERO_ROW;
2750
3574
  }
2751
3575
  }
2752
3576
 
2753
- return { sql: recoveredSql, result: recoveredResult };
3577
+ return { sql: recoveredSql, result: recoveredResult, selfHealed };
2754
3578
  }
2755
3579
 
2756
3580
  async function resolveNestedPCalls(
@@ -2874,6 +3698,86 @@ async function retryAccessPointSqlFix(
2874
3698
  * - Uses AP-specific generator/judge prompts (p() syntax, no coordinates/params)
2875
3699
  * - Skips parameter detection (APs have no parameters)
2876
3700
  */
3701
+ // Handles a failed access-point SQL execution: attempts an SQL-fix retry for
3702
+ // fixable errors, otherwise reports the execution error with fallback suggestions.
3703
+ async function handleAccessPointExecutionError(
3704
+ executeError: Error,
3705
+ params: {
3706
+ finalSql: string;
3707
+ question: string;
3708
+ selectedAPs: TDSServiceSchema[];
3709
+ dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates;
3710
+ modelContextEnrichment?: string;
3711
+ startTime: number;
3712
+ execStartTime: number;
3713
+ metadata: LegendAIProductMetadata;
3714
+ },
3715
+ context: LegendAIOperationContext,
3716
+ ): Promise<void> {
3717
+ const { setMessages } = context;
3718
+ const {
3719
+ finalSql,
3720
+ question,
3721
+ selectedAPs,
3722
+ dataProductCoordinates,
3723
+ modelContextEnrichment,
3724
+ startTime,
3725
+ execStartTime,
3726
+ metadata,
3727
+ } = params;
3728
+ const execErrorType = classifyError(executeError);
3729
+ const errMsg = executeError.message;
3730
+ const errorCategory = categorizeExecutionError(errMsg, executeError);
3731
+
3732
+ addThinkingStep(
3733
+ setMessages,
3734
+ `Execution failed: ${errMsg.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
3735
+ );
3736
+
3737
+ if (errorCategory === ExecutionErrorCategory.SQL_FIXABLE) {
3738
+ const retried = await retryAccessPointSqlFix(
3739
+ {
3740
+ failedSql: finalSql,
3741
+ question,
3742
+ errMsg,
3743
+ selectedAPs,
3744
+ dataProductCoordinates,
3745
+ ...(modelContextEnrichment === undefined
3746
+ ? {}
3747
+ : { modelContextEnrichment }),
3748
+ startTime,
3749
+ },
3750
+ context,
3751
+ metadata,
3752
+ );
3753
+ if (retried) {
3754
+ return;
3755
+ }
3756
+ }
3757
+
3758
+ finishWithThinkingError(
3759
+ setMessages,
3760
+ buildExecutionErrorMessage(errMsg, selectedAPs),
3761
+ startTime,
3762
+ execErrorType === LegendAIErrorType.GENERAL
3763
+ ? LegendAIErrorType.EXECUTION
3764
+ : execErrorType,
3765
+ );
3766
+ const queriedGroups = [
3767
+ ...new Set(
3768
+ selectedAPs
3769
+ .map((ap) => ap.accessPointGroupTitle)
3770
+ .filter((t): t is string => t !== undefined),
3771
+ ),
3772
+ ];
3773
+ updateLastAssistant(setMessages, () => ({
3774
+ execTime: elapsedSeconds(execStartTime, 2),
3775
+ isExecuting: false,
3776
+ suggestedQueries: buildFallbackSuggestions(selectedAPs),
3777
+ queriedAccessPointGroups: queriedGroups,
3778
+ }));
3779
+ }
3780
+
2877
3781
  async function processAccessPointQuery(
2878
3782
  question: string,
2879
3783
  accessPoints: TDSServiceSchema[],
@@ -2889,10 +3793,33 @@ async function processAccessPointQuery(
2889
3793
 
2890
3794
  const selectedAPs = await selectBestServices(question, accessPoints, context);
2891
3795
 
3796
+ const currentAccessPoints = new Set(
3797
+ selectedAPs.map((ap) => ap.pattern.replace(/^\//, '')),
3798
+ );
3799
+ const scopedContext: LegendAIOperationContext = {
3800
+ ...context,
3801
+ history: filterHistoryForAccessPoints(context.history, currentAccessPoints),
3802
+ };
3803
+
3804
+ try {
3805
+ addThinkingStep(setMessages, 'Grounding filter values from real data...');
3806
+ await withTimeout(
3807
+ plugin.enrichAccessPointSampleValues(
3808
+ selectedAPs,
3809
+ dataProductCoordinates,
3810
+ config,
3811
+ ),
3812
+ VALUE_GROUNDING_TIME_BUDGET_MS,
3813
+ );
3814
+ } catch (error) {
3815
+ assertErrorThrown(error);
3816
+ addThinkingStep(setMessages, 'Value grounding skipped');
3817
+ }
3818
+
2892
3819
  const judgedSql = await generateAndJudgeAccessPointSql(
2893
3820
  question,
2894
3821
  selectedAPs,
2895
- context,
3822
+ scopedContext,
2896
3823
  startTime,
2897
3824
  modelContextEnrichment,
2898
3825
  );
@@ -2921,7 +3848,7 @@ async function processAccessPointQuery(
2921
3848
  judgedSql,
2922
3849
  question,
2923
3850
  selectedAPs,
2924
- context,
3851
+ scopedContext,
2925
3852
  modelContextEnrichment,
2926
3853
  );
2927
3854
 
@@ -2929,83 +3856,69 @@ async function processAccessPointQuery(
2929
3856
  sql: finalSql,
2930
3857
  sqlGenTime: sqlGenTimeValue,
2931
3858
  isExecuting: true,
3859
+ queriedAccessPoints: orderedAccessPointsFromSql(finalSql),
2932
3860
  }));
2933
3861
 
2934
3862
  const execStartTime = Date.now();
2935
3863
  try {
2936
- const rawResult = await executeSqlForServices(
2937
- finalSql,
3864
+ let execSql = finalSql;
3865
+ let rawResult = await executeSqlForServices(
3866
+ execSql,
2938
3867
  selectedAPs,
2939
3868
  dataProductCoordinates,
2940
3869
  plugin,
2941
3870
  config,
2942
3871
  );
2943
3872
 
3873
+ if (rawResult.rows.length === 0) {
3874
+ addThinkingStep(
3875
+ setMessages,
3876
+ "Checking filter values against the column's actual values...",
3877
+ );
3878
+ const resolved = await attemptDistinctValueRecovery(
3879
+ execSql,
3880
+ selectedAPs,
3881
+ dataProductCoordinates,
3882
+ scopedContext,
3883
+ );
3884
+ if (resolved) {
3885
+ execSql = resolved.sql;
3886
+ rawResult = resolved.result;
3887
+ updateLastAssistant(setMessages, () => ({ sql: execSql }));
3888
+ }
3889
+ }
3890
+
2944
3891
  await reportQueryResults(
2945
3892
  {
2946
- currentSql: finalSql,
3893
+ currentSql: execSql,
2947
3894
  sqlResult: rawResult,
2948
3895
  question,
2949
3896
  services: selectedAPs,
3897
+ allAccessPoints: accessPoints,
2950
3898
  },
2951
3899
  metadata,
2952
- context,
3900
+ scopedContext,
2953
3901
  startTime,
2954
3902
  false,
2955
3903
  );
2956
3904
  } catch (executeError) {
2957
3905
  assertErrorThrown(executeError);
2958
- const execErrorType = classifyError(executeError);
2959
- const errMsg = executeError.message;
2960
- const errorCategory = categorizeExecutionError(errMsg, executeError);
2961
-
2962
- addThinkingStep(
2963
- setMessages,
2964
- `Execution failed: ${errMsg.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
2965
- );
2966
-
2967
- if (errorCategory === ExecutionErrorCategory.SQL_FIXABLE) {
2968
- const retried = await retryAccessPointSqlFix(
2969
- {
2970
- failedSql: finalSql,
2971
- question,
2972
- errMsg,
2973
- selectedAPs,
2974
- dataProductCoordinates,
2975
- ...(modelContextEnrichment === undefined
2976
- ? {}
2977
- : { modelContextEnrichment }),
2978
- startTime,
2979
- },
2980
- context,
3906
+ await handleAccessPointExecutionError(
3907
+ executeError,
3908
+ {
3909
+ finalSql,
3910
+ question,
3911
+ selectedAPs,
3912
+ dataProductCoordinates,
3913
+ ...(modelContextEnrichment === undefined
3914
+ ? {}
3915
+ : { modelContextEnrichment }),
3916
+ startTime,
3917
+ execStartTime,
2981
3918
  metadata,
2982
- );
2983
- if (retried) {
2984
- return;
2985
- }
2986
- }
2987
-
2988
- finishWithThinkingError(
2989
- setMessages,
2990
- buildExecutionErrorMessage(errMsg, selectedAPs),
2991
- startTime,
2992
- execErrorType === LegendAIErrorType.GENERAL
2993
- ? LegendAIErrorType.EXECUTION
2994
- : execErrorType,
3919
+ },
3920
+ scopedContext,
2995
3921
  );
2996
- const queriedGroups = [
2997
- ...new Set(
2998
- selectedAPs
2999
- .map((ap) => ap.accessPointGroupTitle)
3000
- .filter((t): t is string => t !== undefined),
3001
- ),
3002
- ];
3003
- updateLastAssistant(setMessages, () => ({
3004
- execTime: elapsedSeconds(execStartTime, 2),
3005
- isExecuting: false,
3006
- suggestedQueries: buildFallbackSuggestions(selectedAPs),
3007
- queriedAccessPointGroups: queriedGroups,
3008
- }));
3009
3922
  }
3010
3923
  }
3011
3924
 
@@ -3018,6 +3931,7 @@ async function processDataQuery(
3018
3931
  startTime: number,
3019
3932
  orchestratorOptions?: LegendAIOrchestratorOptionsParam,
3020
3933
  modelContextEnrichment?: string,
3934
+ approachText?: string,
3021
3935
  ): Promise<void> {
3022
3936
  const { config, setMessages } = context;
3023
3937
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
@@ -3172,6 +4086,10 @@ async function processDataQuery(
3172
4086
  startTime,
3173
4087
  hasOrchestratorFallback,
3174
4088
  );
4089
+
4090
+ if (approachText) {
4091
+ attachMetadataOverview(setMessages, approachText);
4092
+ }
3175
4093
  }
3176
4094
 
3177
4095
  function splitServicesByType(services: TDSServiceSchema[]): {
@@ -3197,6 +4115,7 @@ async function routeToAccessPointOrData(
3197
4115
  startTime: number,
3198
4116
  orchestratorOpts: LegendAIOrchestratorOptionsParam | undefined,
3199
4117
  modelContextEnrichment?: string,
4118
+ approachText?: string,
3200
4119
  ): Promise<void> {
3201
4120
  const dataProductCoordinates = orchestratorOpts?.dataProductCoordinates;
3202
4121
  const { tdsServices, accessPoints } = splitServicesByType(services);
@@ -3224,10 +4143,30 @@ async function routeToAccessPointOrData(
3224
4143
  startTime,
3225
4144
  orchestratorOpts,
3226
4145
  modelContextEnrichment,
4146
+ approachText,
3227
4147
  );
3228
4148
  }
3229
4149
  }
3230
4150
 
4151
+ /**
4152
+ * Compute a value on first use and cache it. The data-query and metadata
4153
+ * answer paths each need a different (potentially large) model-context string,
4154
+ * but only one path runs per question. Deferring the build means we never pay
4155
+ * to construct the string the chosen path doesn't consume, and the build
4156
+ * happens inside the caller's try/catch rather than eagerly at entry.
4157
+ */
4158
+ function lazyMemo<T>(compute: () => T): () => T {
4159
+ let computed = false;
4160
+ let value: T;
4161
+ return (): T => {
4162
+ if (!computed) {
4163
+ value = compute();
4164
+ computed = true;
4165
+ }
4166
+ return value;
4167
+ };
4168
+ }
4169
+
3231
4170
  export async function processQuestion(
3232
4171
  question: string,
3233
4172
  services: TDSServiceSchema[],
@@ -3240,9 +4179,19 @@ export async function processQuestion(
3240
4179
  ): Promise<void> {
3241
4180
  const { config, plugin, setMessages } = context;
3242
4181
  const startTime = Date.now();
3243
- const modelContextEnrichment = modelContext
3244
- ? buildModelContextEnrichmentText(modelContext, services)
3245
- : undefined;
4182
+ const getModelContextEnrichment = lazyMemo(() =>
4183
+ modelContext
4184
+ ? buildModelContextEnrichmentText(modelContext, services)
4185
+ : undefined,
4186
+ );
4187
+ const getMetadataEnrichment = lazyMemo(() =>
4188
+ modelContext ? buildModelCatalogText(modelContext) : undefined,
4189
+ );
4190
+ const getApproachText = lazyMemo(() =>
4191
+ modelContext
4192
+ ? buildDataQueryApproachText(question, modelContext)
4193
+ : undefined,
4194
+ );
3246
4195
 
3247
4196
  try {
3248
4197
  addThinkingStep(setMessages, 'Analyzing your question...');
@@ -3264,7 +4213,7 @@ export async function processQuestion(
3264
4213
  startTime,
3265
4214
  false,
3266
4215
  services,
3267
- modelContextEnrichment,
4216
+ getMetadataEnrichment(),
3268
4217
  );
3269
4218
  if (config.orchestratorUrl && dataProductCoordinates) {
3270
4219
  updateLastAssistant(setMessages, () => ({
@@ -3293,7 +4242,7 @@ export async function processQuestion(
3293
4242
  startTime,
3294
4243
  true,
3295
4244
  services,
3296
- modelContextEnrichment,
4245
+ getMetadataEnrichment(),
3297
4246
  );
3298
4247
  return;
3299
4248
  }
@@ -3307,7 +4256,8 @@ export async function processQuestion(
3307
4256
  context,
3308
4257
  startTime,
3309
4258
  orchestratorOpts,
3310
- modelContextEnrichment,
4259
+ getModelContextEnrichment(),
4260
+ getApproachText(),
3311
4261
  );
3312
4262
  } catch (sqlError) {
3313
4263
  assertErrorThrown(sqlError);
@@ -3322,7 +4272,7 @@ export async function processQuestion(
3322
4272
  startTime,
3323
4273
  true,
3324
4274
  services,
3325
- modelContextEnrichment,
4275
+ getMetadataEnrichment(),
3326
4276
  );
3327
4277
  appendFallbackSuggestions(setMessages, services);
3328
4278
  }
@@ -3350,6 +4300,7 @@ async function routeDataQueryWithErrorHandling(
3350
4300
  context: LegendAIOperationContext,
3351
4301
  orchestratorOptions: LegendAIOrchestratorOptionsParam | undefined,
3352
4302
  modelContextEnrichment?: string,
4303
+ approachText?: string,
3353
4304
  ): Promise<void> {
3354
4305
  const { setMessages } = context;
3355
4306
  const startTime = Date.now();
@@ -3364,6 +4315,7 @@ async function routeDataQueryWithErrorHandling(
3364
4315
  startTime,
3365
4316
  orchestratorOptions,
3366
4317
  modelContextEnrichment,
4318
+ approachText,
3367
4319
  );
3368
4320
  } catch (error) {
3369
4321
  assertErrorThrown(error);
@@ -3392,9 +4344,19 @@ export async function processQuestionWithIntent(
3392
4344
  ): Promise<void> {
3393
4345
  const { config, setMessages } = context;
3394
4346
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
3395
- const modelContextEnrichment = modelContext
3396
- ? buildModelContextEnrichmentText(modelContext, services)
3397
- : undefined;
4347
+ const getModelContextEnrichment = lazyMemo(() =>
4348
+ modelContext
4349
+ ? buildModelContextEnrichmentText(modelContext, services)
4350
+ : undefined,
4351
+ );
4352
+ const getMetadataEnrichment = lazyMemo(() =>
4353
+ modelContext ? buildModelCatalogText(modelContext) : undefined,
4354
+ );
4355
+ const getApproachText = lazyMemo(() =>
4356
+ modelContext
4357
+ ? buildDataQueryApproachText(question, modelContext)
4358
+ : undefined,
4359
+ );
3398
4360
 
3399
4361
  if (intent === LegendAIQuestionIntent.METADATA) {
3400
4362
  const startTime = Date.now();
@@ -3406,7 +4368,7 @@ export async function processQuestionWithIntent(
3406
4368
  startTime,
3407
4369
  services.length > 0,
3408
4370
  services,
3409
- modelContextEnrichment,
4371
+ getMetadataEnrichment(),
3410
4372
  );
3411
4373
  } catch (error) {
3412
4374
  assertErrorThrown(error);
@@ -3437,7 +4399,8 @@ export async function processQuestionWithIntent(
3437
4399
  metadata,
3438
4400
  context,
3439
4401
  orchestratorOptions,
3440
- modelContextEnrichment,
4402
+ getModelContextEnrichment(),
4403
+ getApproachText(),
3441
4404
  );
3442
4405
  return;
3443
4406
  }
@@ -3449,7 +4412,7 @@ export async function processQuestionWithIntent(
3449
4412
  startTime,
3450
4413
  false,
3451
4414
  services,
3452
- modelContextEnrichment,
4415
+ getMetadataEnrichment(),
3453
4416
  );
3454
4417
  updateLastAssistant(setMessages, () => ({
3455
4418
  fallbackAction: {
@@ -3467,6 +4430,7 @@ export async function processQuestionWithIntent(
3467
4430
  metadata,
3468
4431
  context,
3469
4432
  orchestratorOptions,
3470
- modelContextEnrichment,
4433
+ getModelContextEnrichment(),
4434
+ getApproachText(),
3471
4435
  );
3472
4436
  }