@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
@@ -13,34 +13,39 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { assertErrorThrown, isPlainObject, uuid } from '@finos/legend-shared';
17
- import { LegendAIQuestionIntent, LegendAIResponseOutcome, LegendAIThinkingStepStatus, LegendAIMessageRole, LegendAIErrorType, LegendAIServiceError, LegendAIUnsupportedEngineShapeError, TDSServiceSourceType, buildColumnDefsFromNames, LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID, getTodayISO, } from '../LegendAITypes.js';
16
+ import { assertErrorThrown, guaranteeNonNullable, isNonNullable, uuid, } from '@finos/legend-shared';
17
+ import { LegendAISelfHealKind, LegendAIQuestionIntent, LegendAIResponseOutcome, LegendAIThinkingStepStatus, LegendAIMessageRole, LegendAIErrorType, LegendAIExecutionTimeoutError, LegendAIServiceError, LegendAIUnsupportedEngineShapeError, TDSServiceSourceType, buildColumnDefsFromNames, LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID, LEGEND_AI_ALTERNATE_ROOT_ACTION_ID, LEGEND_AI_FEEDBACK_PROMPT, getTodayISO, } from '../LegendAITypes.js';
18
18
  import { LegendAIJudgeVerdict, } from '../LegendAI_LegendApplicationPlugin_Extension.js';
19
19
  import { extractElementNameFromPath, } from '@finos/legend-graph';
20
- import { buildEnrichedBusinessContext, buildModelContextEnrichmentText, findBestAlternateRoot, splitIdentifierTokens, tokenizeText, } from '../LegendAIDocEnrichment.js';
20
+ import { buildEnrichedBusinessContext, buildModelContextEnrichmentText, buildModelCatalogText, buildDataQueryApproachText, findBestAlternateRoot, rankEntities, relaxExactStringFilters, extractFilteredColumns, buildProbedValueHints, splitIdentifierTokens, tokenizeText, } from '../LegendAIDocEnrichment.js';
21
21
  import { isFuzzyMatch, preFilterServicesByRelevance, } from '../LegendAIServiceRetrieval.js';
22
22
  import { isNumericColumn, isStringColumn, } from '../components/LegendAIChatHelpers.js';
23
+ import { HAS_LIMIT_PATTERN, AP_CALL_PATTERN, servicePId, pureRelationColumnRef, } from './LegendAISqlHelpers.js';
24
+ import { buildCrossJoinZeroRowExplanation, buildJoinablePairSuggestions, } from './LegendAIJoinAnalysis.js';
25
+ import { boundCrossAccessPointJoinDrivingSide, sanitizeJoinDuplicateColumns, sanitizeJoinOrderBy, sanitizeJoinSameKeyColumns, wrapBareJoinAccessPoints, } from './LegendAISqlJoinSanitizers.js';
23
26
  const MAX_ERROR_MESSAGE_LENGTH = 500;
27
+ const MAX_DISPLAYED_ERROR_LENGTH = 4000;
24
28
  const MAX_THINKING_ERROR_PREVIEW_LENGTH = 200;
25
29
  const DEFAULT_MAX_JUDGE_ATTEMPTS = 5;
26
30
  const DEFAULT_MAX_EXECUTION_RETRIES = 3;
27
31
  const ANALYSIS_TIMEOUT_MS = 15_000;
28
32
  const ORCHESTRATOR_GENERATION_TIMEOUT_MS = 120_000;
33
+ const DISTINCT_PROBE_ROW_LIMIT = 5000;
34
+ const MAX_RECOVERED_FILTER_COLUMNS = 4;
29
35
  const EXECUTION_TIMEOUT_MS = 300_000;
30
36
  const ANALYSIS_PREVIEW_ROW_LIMIT = 3;
31
37
  const ANALYSIS_PREVIEW_VALUE_LIMIT = 40;
38
+ const MAX_ANALYSIS_ROWS = 100;
32
39
  const MAX_NON_SQL_PASS_ATTEMPTS = 2;
33
- const JOIN_PATTERN = /\bJOIN\b/i;
34
- const ORDER_BY_SPLIT = /\bORDER\s+BY\b/i;
35
40
  const UNION_ALL_PATTERN = /\bUNION\s+ALL\b/i;
36
41
  const LITERAL_COL_PATTERN = /,\s*'[^']*'\s+AS\s+(?:"[^"]+"|[a-z]\w*)/gi;
37
- const SELECT_ALIAS_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"\s+AS\s+(?:"(?<qAlias>[^"]+)"|(?<uAlias>\w+))/gi;
38
- const ALIAS_DOT_COL_PATTERN = /\b(?<tbl>[a-z]\w*)\s*\.\s*"(?<col>[^"]+)"/gi;
39
42
  const SERVICE_CALL_PATTERN = /\bservice\s*\([^()]*\)/gi;
40
43
  const DEFAULT_SAFETY_LIMIT = 1000;
41
- const HAS_LIMIT_PATTERN = /\bLIMIT\s+\d+/i;
42
- const HAS_AGGREGATION_PATTERN = /\bGROUP\s+BY\b|\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(|\bMIN\s*\(|\bMAX\s*\(/i;
44
+ const HAS_AGGREGATION_PATTERN = /\bGROUP\s+BY\b|\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(|\bSELECT\s+DISTINCT\b|\bHAVING\b|\bQUALIFY\b/i;
43
45
  const MAX_SERVICES_FOR_LLM_SELECTION = 30;
46
+ const VALUE_GROUNDING_TIME_BUDGET_MS = 4000;
47
+ const AP_SQL_EXECUTION_TIMEOUT_MS = 90_000;
48
+ const AP_SQL_EXECUTION_TIMEOUT_MESSAGE = '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.';
44
49
  const ORCHESTRATOR_FALLBACK_LABEL = 'Try Legend AI Orchestrator';
45
50
  const SQL_GENERATION_FAILURE_WITH_ORCHESTRATOR = 'SQL generation could not handle this query. You can try the Legend AI Orchestrator to generate a Pure query instead.';
46
51
  const SQL_GENERATION_FAILURE_NO_ORCHESTRATOR = 'SQL generation could not handle this query. Try rephrasing your question.';
@@ -50,6 +55,11 @@ const SERVICE_PARAM_DATE_LIKE_PATTERNS = [
50
55
  /start|end|from|until|begin|report|cutoff|valuation|pricing/i,
51
56
  ];
52
57
  const GENERIC_TABLE_PATTERNS = /combined|consolidated|all|master|summary/i;
58
+ const MIN_ALL_NULL_COLUMNS_FOR_JOIN_HINT = 2;
59
+ const JOIN_HINT_COLUMN_PREVIEW_LIMIT = 3;
60
+ const MAX_AGGREGATE_COLUMNS = 12;
61
+ const MAX_AGGREGATE_VALUES_PER_COLUMN = 8;
62
+ const MIN_NUMERIC_DISTINCT_RATIO = 0.5;
53
63
  function isLikelySqlQuery(text) {
54
64
  const trimmed = text.trim().toLowerCase();
55
65
  return (trimmed.startsWith('select') ||
@@ -102,6 +112,7 @@ export function createMessagePair(text) {
102
112
  suggestedQueries: [],
103
113
  fallbackAction: null,
104
114
  queriedAccessPointGroups: [],
115
+ queriedAccessPoints: [],
105
116
  },
106
117
  ];
107
118
  }
@@ -173,6 +184,9 @@ const EXECUTION_ERROR_RULES = [
173
184
  },
174
185
  ];
175
186
  export function categorizeExecutionError(errMsg, error) {
187
+ if (error instanceof LegendAIExecutionTimeoutError) {
188
+ return ExecutionErrorCategory.NONE;
189
+ }
176
190
  if (error instanceof LegendAIUnsupportedEngineShapeError) {
177
191
  return ExecutionErrorCategory.SQL_FIXABLE;
178
192
  }
@@ -187,17 +201,52 @@ export function categorizeExecutionError(errMsg, error) {
187
201
  }
188
202
  return ExecutionErrorCategory.NONE;
189
203
  }
204
+ const ACCESS_ERROR_PATTERN = /insufficient privileges|access.*denied|permission.*denied|not authorized|unauthorized|403|entitlement/i;
205
+ // Whether an execution error message indicates an access/permission problem (as
206
+ // opposed to a SQL compilation or schema error), gating the "Request Access" UI.
207
+ export function looksLikeAccessError(errorMsg) {
208
+ return errorMsg !== undefined && ACCESS_ERROR_PATTERN.test(errorMsg);
209
+ }
190
210
  export function finishWithThinkingError(setMessages, errorMsg, startTime, errorType) {
191
211
  updateLastAssistant(setMessages, (msg) => ({
192
212
  thinkingSteps: msg.thinkingSteps.map((s) => s.status === LegendAIThinkingStepStatus.ACTIVE
193
213
  ? { ...s, status: LegendAIThinkingStepStatus.ERROR }
194
214
  : s),
195
- error: errorMsg.slice(0, MAX_ERROR_MESSAGE_LENGTH),
215
+ error: errorMsg.slice(0, MAX_DISPLAYED_ERROR_LENGTH),
196
216
  errorType: errorType ?? null,
197
217
  isProcessing: false,
198
218
  thinkingDuration: elapsedSeconds(startTime),
199
219
  }));
200
220
  }
221
+ export function filterHistoryForAccessPoints(history, currentAccessPoints) {
222
+ if (currentAccessPoints.size === 0) {
223
+ return history;
224
+ }
225
+ return history.filter((turn) => {
226
+ if (!turn.queriedAccessPoints || turn.queriedAccessPoints.length === 0) {
227
+ return true;
228
+ }
229
+ return turn.queriedAccessPoints.some((ap) => currentAccessPoints.has(ap));
230
+ });
231
+ }
232
+ // Access-point names referenced by `p('...')` calls in the SQL, in
233
+ // first-appearance (driving-first) order and de-duplicated. Computed once at
234
+ // generation to persist on the message so downstream consumers (UI, history,
235
+ // integrations) resolve access points structurally instead of re-parsing SQL.
236
+ function orderedAccessPointsFromSql(sql) {
237
+ const ordered = [];
238
+ const seen = new Set();
239
+ for (const match of sql.matchAll(AP_CALL_PATTERN)) {
240
+ const path = match.groups?.pId ?? '';
241
+ const dotIdx = path.lastIndexOf('.');
242
+ const apName = dotIdx === -1 ? path : path.slice(dotIdx + 1);
243
+ if (apName.length > 0 && !seen.has(apName)) {
244
+ seen.add(apName);
245
+ ordered.push(apName);
246
+ }
247
+ }
248
+ return ordered;
249
+ }
201
250
  function buildTurnFromAssistant(userText, asstMsg) {
202
251
  if (asstMsg.sql) {
203
252
  const turn = {
@@ -205,6 +254,9 @@ function buildTurnFromAssistant(userText, asstMsg) {
205
254
  sql: asstMsg.sql,
206
255
  intent: LegendAIQuestionIntent.DATA_QUERY,
207
256
  };
257
+ if (asstMsg.queriedAccessPoints.length > 0) {
258
+ turn.queriedAccessPoints = asstMsg.queriedAccessPoints;
259
+ }
208
260
  if (asstMsg.error) {
209
261
  turn.resultSummary = `ERROR: ${asstMsg.error.slice(0, 200)}`;
210
262
  }
@@ -376,10 +428,10 @@ export function buildExecutionErrorMessage(errStr, services) {
376
428
  }
377
429
  return errParts.join('');
378
430
  }
379
- if (errLower.includes('rename(~')) {
431
+ if (errLower.includes('duplicate column')) {
380
432
  return [
381
- 'Cross-access-point JOINs on columns with the same name are not yet supported by the execution engine.',
382
- '\nTry querying each access point separately, or ask a metadata question instead.',
433
+ 'This join produced two output columns with the same name, which the execution engine cannot return.',
434
+ '\nGive each selected column a unique alias (e.g. `<column> AS <accessPoint>_<column>`) so the joined result stays distinct.',
383
435
  ].join('');
384
436
  }
385
437
  if (/can't find a match for function '\w+\(Timestamp/i.test(errStr) &&
@@ -502,11 +554,8 @@ function buildJoinDiagnosticNote(query, columns, rows) {
502
554
  const someDataColumns = [];
503
555
  for (const col of columns) {
504
556
  const allNull = rows.every((row) => {
505
- if (isPlainObject(row)) {
506
- const val = row[col];
507
- return val === null || val === undefined;
508
- }
509
- return true;
557
+ const val = row[col];
558
+ return val === null || val === undefined;
510
559
  });
511
560
  if (allNull) {
512
561
  allNullColumns.push(col);
@@ -517,26 +566,84 @@ function buildJoinDiagnosticNote(query, columns, rows) {
517
566
  }
518
567
  if (allNullColumns.length > 0 &&
519
568
  someDataColumns.length > 0 &&
520
- allNullColumns.length >= 2) {
521
- return (`Note: ${allNullColumns.length} columns (${allNullColumns.slice(0, 3).join(', ')}${allNullColumns.length > 3 ? ', ...' : ''}) ` +
569
+ allNullColumns.length >= MIN_ALL_NULL_COLUMNS_FOR_JOIN_HINT) {
570
+ return (`Note: ${allNullColumns.length} columns (${allNullColumns.slice(0, JOIN_HINT_COLUMN_PREVIEW_LIMIT).join(', ')}${allNullColumns.length > JOIN_HINT_COLUMN_PREVIEW_LIMIT ? ', ...' : ''}) ` +
522
571
  'returned entirely NULL values. This typically means the joined services do not share ' +
523
572
  'overlapping values for the join key. The services may track different records. ' +
524
573
  'Try querying each service separately to explore their data independently.');
525
574
  }
526
575
  return '';
527
576
  }
577
+ function createColumnDistribution(column) {
578
+ return {
579
+ column,
580
+ counts: new Map(),
581
+ present: 0,
582
+ numeric: 0,
583
+ min: Number.POSITIVE_INFINITY,
584
+ max: Number.NEGATIVE_INFINITY,
585
+ };
586
+ }
587
+ // Formats one accumulated column distribution into a single line:
588
+ // "(all empty)", a numeric range, or the top values with their counts.
589
+ function formatColumnDistribution(stats) {
590
+ const { column, counts, present, numeric, min, max } = stats;
591
+ if (present === 0) {
592
+ return `- ${column}: (all empty)`;
593
+ }
594
+ if (numeric === present &&
595
+ counts.size > present * MIN_NUMERIC_DISTINCT_RATIO) {
596
+ return `- ${column}: numeric, range ${min}–${max} (${present} values)`;
597
+ }
598
+ const top = Array.from(counts.entries())
599
+ .sort((a, b) => b[1] - a[1])
600
+ .slice(0, MAX_AGGREGATE_VALUES_PER_COLUMN)
601
+ .map(([value, count]) => `${value} (${count})`)
602
+ .join(', ');
603
+ const more = counts.size > MAX_AGGREGATE_VALUES_PER_COLUMN
604
+ ? `, … ${counts.size} distinct total`
605
+ : '';
606
+ return `- ${column}: ${top}${more}`;
607
+ }
608
+ export function buildDataAggregatesText(columns, rows) {
609
+ if (rows.length === 0) {
610
+ return 'Total rows: 0';
611
+ }
612
+ const distributions = columns
613
+ .slice(0, MAX_AGGREGATE_COLUMNS)
614
+ .map(createColumnDistribution);
615
+ for (const row of rows) {
616
+ for (const stats of distributions) {
617
+ const raw = row[stats.column];
618
+ if (raw === null || raw === undefined || raw === '') {
619
+ continue;
620
+ }
621
+ stats.present += 1;
622
+ const asString = String(raw);
623
+ stats.counts.set(asString, (stats.counts.get(asString) ?? 0) + 1);
624
+ const asNumber = typeof raw === 'number' ? raw : Number(asString);
625
+ if (asString.trim() !== '' && !Number.isNaN(asNumber)) {
626
+ stats.numeric += 1;
627
+ stats.min = Math.min(stats.min, asNumber);
628
+ stats.max = Math.max(stats.max, asNumber);
629
+ }
630
+ }
631
+ }
632
+ return [
633
+ `Total rows: ${rows.length}`,
634
+ 'Column value distributions over ALL rows (value: count):',
635
+ ...distributions.map(formatColumnDistribution),
636
+ ].join('\n');
637
+ }
528
638
  function buildDeterministicResultSummary(question, query, columns, rows) {
529
639
  const selectedColumns = columns.length === 0 ? 'none' : columns.join(', ');
530
640
  const rowCount = rows.length;
531
641
  const previewRows = rows
532
642
  .slice(0, ANALYSIS_PREVIEW_ROW_LIMIT)
533
643
  .map((row, index) => {
534
- if (isPlainObject(row)) {
535
- const entries = Object.entries(row).slice(0, 4);
536
- const formattedEntries = entries.map(([key, value]) => `${key}: ${formatPreviewValue(value)}`);
537
- return `${index + 1}. ${formattedEntries.join(', ')}`;
538
- }
539
- return `${index + 1}. ${formatPreviewValue(row)}`;
644
+ const entries = Object.entries(row).slice(0, 4);
645
+ const formattedEntries = entries.map(([key, value]) => `${key}: ${formatPreviewValue(value)}`);
646
+ return `${index + 1}. ${formattedEntries.join(', ')}`;
540
647
  })
541
648
  .join('\n');
542
649
  const coverageNote = buildQuestionCoverageNote(question, columns);
@@ -610,7 +717,9 @@ function handleNonSqlPass(setMessages, nonSqlPassAttempts, attempt, maxAttempts)
610
717
  return 'continue';
611
718
  }
612
719
  function finalizeJudgeAttempt(currentSql, previousSql, attempt, maxAttempts, setMessages) {
613
- if (currentSql !== previousSql && attempt < maxAttempts) {
720
+ if (normalizeSqlForComparison(currentSql) !==
721
+ normalizeSqlForComparison(previousSql) &&
722
+ attempt < maxAttempts) {
614
723
  return undefined;
615
724
  }
616
725
  addThinkingStep(setMessages, isLikelySqlQuery(currentSql)
@@ -780,7 +889,7 @@ export async function analyzeOrchestratorResults(question, query, execResult, me
780
889
  updateLastAssistant(setMessages, () => ({
781
890
  isProcessing: true,
782
891
  }));
783
- const analysis = await withTimeout(plugin.analyzeQueryResults(question, query, execResult.columns, execResult.rows, metadata, config), ANALYSIS_TIMEOUT_MS);
892
+ const analysis = await withTimeout(plugin.analyzeQueryResults(question, query, execResult.columns, execResult.rows.slice(0, MAX_ANALYSIS_ROWS), metadata, config, execResult.rows.length, buildDataAggregatesText(execResult.columns, execResult.rows)), ANALYSIS_TIMEOUT_MS);
784
893
  addThinkingStep(setMessages, 'Verifying answer coverage...');
785
894
  if (analysis) {
786
895
  const coverageNote = buildQuestionCoverageNote(question, execResult.columns);
@@ -850,32 +959,171 @@ async function retryWithAlternateRoot(question, alternateRoot, resolvedEntities,
850
959
  return false;
851
960
  }
852
961
  }
853
- async function handleEmptyOrchestratorResults(question, legendQuery, orchestratorOptions, metadata, resolvedEntities, context, options) {
962
+ async function attemptProbedRegeneration(question, legendQuery, resolvedEntities, orchestratorOptions, metadata, context, options) {
854
963
  const { startTime, modelContext } = options;
964
+ if (!modelContext) {
965
+ return false;
966
+ }
967
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
855
968
  const { config, plugin, setMessages } = context;
969
+ const rootEntity = modelContext.entities.find((entity) => entity.path === resolvedEntities.rootEntity);
970
+ if (!rootEntity) {
971
+ return false;
972
+ }
973
+ const columns = extractFilteredColumns(legendQuery).filter((column) => rootEntity.properties.some((prop) => prop.name === column && !prop.type.includes('::')));
974
+ if (columns.length === 0) {
975
+ return false;
976
+ }
977
+ addThinkingStep(setMessages, 'Re-grounding with actual column values...');
978
+ const probeResults = await Promise.all(columns.slice(0, MAX_RECOVERED_FILTER_COLUMNS).map(async (column) => {
979
+ try {
980
+ const probe = await withTimeout(plugin.executePureQuery(`${resolvedEntities.rootEntity}.all()->project([col(x|$x.${column}, '${column}')])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`, pureExecutionContext, dataProductCoordinates, config), ORCHESTRATOR_GENERATION_TIMEOUT_MS);
981
+ const values = (probe?.rows ?? [])
982
+ .map((row) => row[column])
983
+ .filter(isNonNullable)
984
+ .map(String);
985
+ return values.length > 0
986
+ ? { column, values: Array.from(new Set(values)) }
987
+ : undefined;
988
+ }
989
+ catch (probeError) {
990
+ assertErrorThrown(probeError);
991
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
992
+ return undefined;
993
+ }
994
+ }));
995
+ const probed = new Map();
996
+ for (const entry of probeResults) {
997
+ if (entry !== undefined) {
998
+ probed.set(entry.column, entry.values);
999
+ }
1000
+ }
1001
+ if (probed.size === 0) {
1002
+ return false;
1003
+ }
1004
+ const base = buildEnrichedBusinessContext(question, resolvedEntities.rootEntity, resolvedEntities.relatedEntities, modelContext);
1005
+ const augmentedContext = {
1006
+ ...base,
1007
+ businessContextMatch: {
1008
+ ...base.businessContextMatch,
1009
+ additionalNlModelContext: [
1010
+ ...(base.businessContextMatch?.additionalNlModelContext ?? []),
1011
+ ...buildProbedValueHints(probed),
1012
+ ],
1013
+ },
1014
+ };
1015
+ try {
1016
+ const response = await withTimeout(plugin.generateQueryViaOrchestrator({
1017
+ user_question: question,
1018
+ semantic_search_resolution_details: {
1019
+ data_product_coordinates: dataProductCoordinates,
1020
+ root_entity: resolvedEntities.rootEntity,
1021
+ related_entities: resolvedEntities.relatedEntities,
1022
+ enriched_business_context: augmentedContext,
1023
+ },
1024
+ }, config), ORCHESTRATOR_GENERATION_TIMEOUT_MS);
1025
+ if (!response) {
1026
+ return false;
1027
+ }
1028
+ const safeQuery = ensurePureSafetyLimit(response.legend_query);
1029
+ updateLastAssistant(setMessages, () => ({
1030
+ sql: safeQuery,
1031
+ isExecuting: true,
1032
+ }));
1033
+ const result = await executePureQueryAndReport(safeQuery, pureExecutionContext, dataProductCoordinates, config, plugin, setMessages, startTime);
1034
+ if (result && result.rows.length > 0) {
1035
+ await analyzeOrchestratorResults(question, safeQuery, result, metadata, context, startTime);
1036
+ return true;
1037
+ }
1038
+ return false;
1039
+ }
1040
+ catch (regenerateError) {
1041
+ assertErrorThrown(regenerateError);
1042
+ addThinkingStep(setMessages, `Re-grounded retry failed: ${regenerateError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
1043
+ return false;
1044
+ }
1045
+ }
1046
+ // Retries the query with case-insensitive (relaxed) string filters. Returns
1047
+ // true when the relaxed query returned rows and its results were reported.
1048
+ async function attemptRelaxedFilterRetry(question, legendQuery, orchestratorOptions, metadata, context, startTime) {
1049
+ const { config, plugin, setMessages } = context;
1050
+ const { dataProductCoordinates, pureExecutionContext } = orchestratorOptions;
1051
+ const relaxedQuery = relaxExactStringFilters(legendQuery);
1052
+ if (relaxedQuery === legendQuery) {
1053
+ return false;
1054
+ }
1055
+ addThinkingStep(setMessages, 'Retrying with relaxed (case-insensitive) filters...');
1056
+ try {
1057
+ const safeRelaxed = ensurePureSafetyLimit(relaxedQuery);
1058
+ updateLastAssistant(setMessages, () => ({
1059
+ sql: safeRelaxed,
1060
+ isExecuting: true,
1061
+ }));
1062
+ const relaxedResult = await executePureQueryAndReport(safeRelaxed, pureExecutionContext, dataProductCoordinates, config, plugin, setMessages, startTime);
1063
+ if (relaxedResult && relaxedResult.rows.length > 0) {
1064
+ await analyzeOrchestratorResults(question, relaxedQuery, relaxedResult, metadata, context, startTime);
1065
+ return true;
1066
+ }
1067
+ }
1068
+ catch (relaxError) {
1069
+ assertErrorThrown(relaxError);
1070
+ addThinkingStep(setMessages, `Relaxed-filter retry failed: ${relaxError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
1071
+ }
1072
+ return false;
1073
+ }
1074
+ async function handleEmptyOrchestratorResults(question, legendQuery, orchestratorOptions, metadata, resolvedEntities, context, options) {
1075
+ const { startTime, modelContext } = options;
1076
+ const { setMessages } = context;
1077
+ if (await attemptRelaxedFilterRetry(question, legendQuery, orchestratorOptions, metadata, context, startTime)) {
1078
+ return;
1079
+ }
1080
+ const regenerated = await attemptProbedRegeneration(question, legendQuery, resolvedEntities, orchestratorOptions, metadata, context, { startTime, ...(modelContext === undefined ? {} : { modelContext }) });
1081
+ if (regenerated) {
1082
+ return;
1083
+ }
1084
+ const triedRoots = new Set([resolvedEntities.rootEntity]);
856
1085
  if (resolvedEntities.relatedEntities.length > 0) {
857
1086
  const alternateRoot = modelContext
858
1087
  ? findBestAlternateRoot(resolvedEntities.rootEntity, resolvedEntities.relatedEntities, modelContext)
859
1088
  : resolvedEntities.relatedEntities[0];
860
1089
  if (alternateRoot) {
1090
+ triedRoots.add(alternateRoot);
861
1091
  const succeeded = await retryWithAlternateRoot(question, alternateRoot, resolvedEntities, orchestratorOptions, metadata, context, { startTime, ...(modelContext === undefined ? {} : { modelContext }) });
862
1092
  if (succeeded) {
863
1093
  return;
864
1094
  }
865
1095
  }
866
1096
  }
867
- addThinkingStep(setMessages, 'No results returned \u2014 building contextual guidance...');
868
- updateLastAssistant(setMessages, () => ({ isProcessing: true }));
869
- const fallback = await withTimeout(plugin.buildNoResultsFallback(question, legendQuery, metadata, config), ANALYSIS_TIMEOUT_MS);
870
- if (fallback) {
871
- completeThinkingSteps(setMessages);
872
- updateLastAssistant(setMessages, () => ({
873
- textAnswer: fallback.summary,
874
- suggestedQueries: fallback.suggestedQueries,
875
- isProcessing: false,
876
- thinkingDuration: elapsedSeconds(startTime),
877
- }));
1097
+ addThinkingStep(setMessages, 'No results found for this question.');
1098
+ completeThinkingSteps(setMessages);
1099
+ updateLastAssistant(setMessages, () => ({
1100
+ textAnswer: buildUnanswerableMessage(),
1101
+ suggestedQueries: [],
1102
+ isProcessing: false,
1103
+ thinkingDuration: elapsedSeconds(startTime),
1104
+ }));
1105
+ attachAlternateRootFallback(question, resolvedEntities, triedRoots, modelContext, setMessages);
1106
+ }
1107
+ // Offers a "try a different root entity" fallback action when a not-yet-tried,
1108
+ // positively-ranked root exists for the question.
1109
+ function attachAlternateRootFallback(question, resolvedEntities, triedRoots, modelContext, setMessages) {
1110
+ const nextRoot = modelContext
1111
+ ? rankEntities(question, modelContext).find((ranked) => !triedRoots.has(ranked.entity.path) && ranked.score > 0)?.entity
1112
+ : undefined;
1113
+ if (!nextRoot) {
1114
+ return;
878
1115
  }
1116
+ const nextRelated = resolvedEntities.relatedEntities.filter((path) => path !== nextRoot.path);
1117
+ updateLastAssistant(setMessages, () => ({
1118
+ fallbackAction: {
1119
+ label: `Tried ${extractElementNameFromPath(resolvedEntities.rootEntity)} as the root \u2014 try ${nextRoot.name} instead`,
1120
+ actionId: LEGEND_AI_ALTERNATE_ROOT_ACTION_ID,
1121
+ resolvedEntities: {
1122
+ rootEntity: nextRoot.path,
1123
+ relatedEntities: nextRelated,
1124
+ },
1125
+ },
1126
+ }));
879
1127
  }
880
1128
  async function resolveOrchestrationEntities(question, dataProductCoordinates, context, pureExecutionContext, preResolvedEntities, modelContext) {
881
1129
  const { config, plugin, setMessages } = context;
@@ -892,14 +1140,12 @@ function extractUserErrorFromMessage(errorMessage) {
892
1140
  const marker = 'with error:';
893
1141
  const idx = errorMessage.toLowerCase().indexOf(marker);
894
1142
  if (idx !== -1) {
895
- const after = errorMessage.slice(idx + marker.length).trimStart();
896
- const newlineIdx = after.indexOf('\n');
897
- const reason = (newlineIdx === -1 ? after : after.slice(0, newlineIdx)).trim();
1143
+ const reason = errorMessage.slice(idx + marker.length).trim();
898
1144
  if (reason.length > 0) {
899
- return reason.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1145
+ return reason.slice(0, MAX_DISPLAYED_ERROR_LENGTH);
900
1146
  }
901
1147
  }
902
- return errorMessage.slice(0, MAX_ERROR_MESSAGE_LENGTH);
1148
+ return errorMessage.slice(0, MAX_DISPLAYED_ERROR_LENGTH);
903
1149
  }
904
1150
  async function handleOrchestratorError(error, startTime, question, metadata, context) {
905
1151
  const { config, plugin, setMessages } = context;
@@ -926,7 +1172,38 @@ async function handleOrchestratorError(error, startTime, question, metadata, con
926
1172
  assertErrorThrown(fallbackError);
927
1173
  addThinkingStep(setMessages, `Fallback guidance failed: ${fallbackError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
928
1174
  }
929
- finishWithThinkingError(setMessages, error.message, startTime, orchErrorType);
1175
+ finishWithThinkingError(setMessages, extractUserErrorFromMessage(error.message), startTime, orchErrorType);
1176
+ }
1177
+ // Analyzes a non-empty orchestrator result (or runs empty-result recovery),
1178
+ // attaching the model-approach overview when available.
1179
+ async function reportOrchestratorExecutionResult(params, context) {
1180
+ const { question, safeQuery, execResult, orchestratorOptions, metadata, resolvedEntities, startTime, modelContext, } = params;
1181
+ const { setMessages } = context;
1182
+ try {
1183
+ if (execResult.rows.length > 0) {
1184
+ await analyzeOrchestratorResults(question, safeQuery, execResult, metadata, context, startTime);
1185
+ if (modelContext) {
1186
+ const approachText = buildDataQueryApproachText(question, modelContext, resolvedEntities.rootEntity);
1187
+ if (approachText) {
1188
+ attachMetadataOverview(setMessages, approachText);
1189
+ }
1190
+ }
1191
+ }
1192
+ else {
1193
+ await handleEmptyOrchestratorResults(question, safeQuery, orchestratorOptions, metadata, resolvedEntities, context, { startTime, ...(modelContext === undefined ? {} : { modelContext }) });
1194
+ }
1195
+ }
1196
+ catch (analysisError) {
1197
+ assertErrorThrown(analysisError);
1198
+ addThinkingStep(setMessages, `Result analysis failed: ${analysisError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
1199
+ }
1200
+ finally {
1201
+ completeThinkingSteps(setMessages);
1202
+ updateLastAssistant(setMessages, () => ({
1203
+ isProcessing: false,
1204
+ thinkingDuration: elapsedSeconds(startTime),
1205
+ }));
1206
+ }
930
1207
  }
931
1208
  export async function processQuestionViaOrchestrator(question, dataProductCoordinates, metadata, context, pureExecutionContext, preResolvedEntities, modelContext) {
932
1209
  const { config, plugin, setMessages } = context;
@@ -976,28 +1253,16 @@ export async function processQuestionViaOrchestrator(question, dataProductCoordi
976
1253
  if (!execResult) {
977
1254
  return;
978
1255
  }
979
- try {
980
- if (execResult.rows.length > 0) {
981
- await analyzeOrchestratorResults(question, safeQuery, execResult, metadata, context, startTime);
982
- }
983
- else {
984
- await handleEmptyOrchestratorResults(question, safeQuery, { dataProductCoordinates, pureExecutionContext }, metadata, resolvedEntities, context, {
985
- startTime,
986
- ...(modelContext === undefined ? {} : { modelContext }),
987
- });
988
- }
989
- }
990
- catch (analysisError) {
991
- assertErrorThrown(analysisError);
992
- addThinkingStep(setMessages, `Result analysis failed: ${analysisError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
993
- }
994
- finally {
995
- completeThinkingSteps(setMessages);
996
- updateLastAssistant(setMessages, () => ({
997
- isProcessing: false,
998
- thinkingDuration: elapsedSeconds(startTime),
999
- }));
1000
- }
1256
+ await reportOrchestratorExecutionResult({
1257
+ question,
1258
+ safeQuery,
1259
+ execResult,
1260
+ orchestratorOptions: { dataProductCoordinates, pureExecutionContext },
1261
+ metadata,
1262
+ resolvedEntities,
1263
+ startTime,
1264
+ ...(modelContext === undefined ? {} : { modelContext }),
1265
+ }, context);
1001
1266
  }
1002
1267
  catch (error) {
1003
1268
  assertErrorThrown(error);
@@ -1012,97 +1277,14 @@ export function cleanLlmSqlResponse(raw) {
1012
1277
  .replace(/;\s*$/, '')
1013
1278
  .trim();
1014
1279
  }
1280
+ function normalizeSqlForComparison(sql) {
1281
+ return sql.replaceAll(/\s+/gu, ' ').trim().toLowerCase();
1282
+ }
1015
1283
  export function isValidSqlCorrection(trimmed, currentSql) {
1016
1284
  return (trimmed.length > 0 &&
1017
1285
  trimmed.toLowerCase().startsWith('select') &&
1018
1286
  trimmed !== currentSql);
1019
1287
  }
1020
- export function sanitizeJoinOrderBy(sql) {
1021
- if (!JOIN_PATTERN.test(sql)) {
1022
- return sql;
1023
- }
1024
- const parts = sql.split(ORDER_BY_SPLIT);
1025
- if (parts.length < 2) {
1026
- return sql;
1027
- }
1028
- const beforeOrderBy = parts[0] ?? '';
1029
- const afterOrderBy = parts.slice(1).join('ORDER BY').replace(/^\s+/, '');
1030
- const selectAliases = new Map();
1031
- for (const m of beforeOrderBy.matchAll(SELECT_ALIAS_PATTERN)) {
1032
- const tableAlias = (m.groups?.tbl ?? '').toLowerCase();
1033
- const colName = (m.groups?.col ?? '').toLowerCase();
1034
- const asAlias = m.groups?.qAlias ?? m.groups?.uAlias ?? '';
1035
- selectAliases.set(`${tableAlias}.${colName}`, asAlias);
1036
- }
1037
- if (selectAliases.size === 0) {
1038
- return sql;
1039
- }
1040
- // Build a lookup of the original alias.col text → its SELECT alias replacement.
1041
- // matchAll exposes typed `.groups`, so no cast is needed.
1042
- const replacements = new Map();
1043
- for (const m of afterOrderBy.matchAll(ALIAS_DOT_COL_PATTERN)) {
1044
- const tbl = (m.groups?.tbl ?? '').toLowerCase();
1045
- const col = (m.groups?.col ?? '').toLowerCase();
1046
- const alias = selectAliases.get(`${tbl}.${col}`);
1047
- if (alias) {
1048
- replacements.set(m[0], `"${alias}"`);
1049
- }
1050
- }
1051
- if (replacements.size === 0) {
1052
- return sql;
1053
- }
1054
- const rewritten = afterOrderBy.replaceAll(ALIAS_DOT_COL_PATTERN, (match) => replacements.get(match) ?? match);
1055
- if (rewritten === afterOrderBy) {
1056
- return sql;
1057
- }
1058
- return `${beforeOrderBy}ORDER BY ${rewritten}`;
1059
- }
1060
- export function sanitizeJoinSameKeyColumns(sql, services) {
1061
- if (!JOIN_PATTERN.test(sql)) {
1062
- return sql;
1063
- }
1064
- const joinRegex = /\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;
1065
- let result = sql;
1066
- let match;
1067
- while ((match = joinRegex.exec(sql)) !== null) {
1068
- const pId = match.groups?.pId ?? '';
1069
- const rightAlias = match.groups?.rAlias ?? '';
1070
- const onClause = (match.groups?.onClause ?? '').slice(0, 500);
1071
- const sameKeyMatch = /(?<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(onClause);
1072
- if (!sameKeyMatch) {
1073
- continue;
1074
- }
1075
- const leftCol = sameKeyMatch.groups?.lCol ?? '';
1076
- const rightCol = sameKeyMatch.groups?.rCol ?? '';
1077
- const matchedRightAlias = sameKeyMatch.groups?.rAl ?? '';
1078
- if (leftCol.toLowerCase() !== rightCol.toLowerCase() ||
1079
- matchedRightAlias.toLowerCase() !== rightAlias.toLowerCase()) {
1080
- continue;
1081
- }
1082
- const renamedKey = `${rightAlias}_${rightCol}`;
1083
- const columnList = buildSubqueryColumnList(pId, rightCol, renamedKey, services);
1084
- const originalFragment = `p('${pId}') AS ${rightAlias}`;
1085
- const subqueryFragment = `(SELECT ${columnList} FROM p('${pId}')) AS ${rightAlias}`;
1086
- const oldOnRef = `${rightAlias}."${rightCol}"`;
1087
- const newOnRef = `${rightAlias}."${renamedKey}"`;
1088
- result = result.replace(originalFragment, subqueryFragment);
1089
- result = result.replaceAll(oldOnRef, newOnRef);
1090
- }
1091
- return result;
1092
- }
1093
- function buildSubqueryColumnList(pId, originalKey, renamedKey, services) {
1094
- const svc = services?.find((s) => s.dataProductPath &&
1095
- s.pattern &&
1096
- pId === `${s.dataProductPath}.${s.pattern.replace(/^\//, '')}`);
1097
- if (!svc || svc.columns.length === 0) {
1098
- return `"${originalKey}" AS "${renamedKey}", *`;
1099
- }
1100
- return svc.columns
1101
- .map((c) => c.name.toLowerCase() === originalKey.toLowerCase()
1102
- ? `"${c.name}" AS "${renamedKey}"`
1103
- : `"${c.name}"`)
1104
- .join(', ');
1105
- }
1106
1288
  export function sanitizeLiteralColumns(sql) {
1107
1289
  if (!UNION_ALL_PATTERN.test(sql)) {
1108
1290
  return sql;
@@ -1335,7 +1517,10 @@ export function ensureSafeLimit(sql, limit = DEFAULT_SAFETY_LIMIT) {
1335
1517
  return `${sql.trimEnd()}\nLIMIT ${limit}`;
1336
1518
  }
1337
1519
  function prepareSafeSql(sql, services) {
1338
- const safeSql = ensureSafeLimit(ensureDateParameters(sanitizeLiteralColumns(sanitizeJoinSameKeyColumns(sanitizeJoinOrderBy(sql), services)), services));
1520
+ const joined = sanitizeLiteralColumns(sanitizeJoinSameKeyColumns(sanitizeJoinOrderBy(sanitizeJoinDuplicateColumns(sql, services)), services));
1521
+ const wrapped = wrapBareJoinAccessPoints(joined, services);
1522
+ const bounded = boundCrossAccessPointJoinDrivingSide(wrapped);
1523
+ const safeSql = ensureSafeLimit(ensureDateParameters(bounded, services));
1339
1524
  const unsupported = detectUnsupportedEnginePattern(safeSql);
1340
1525
  if (unsupported) {
1341
1526
  throw new LegendAIUnsupportedEngineShapeError(unsupported.hint);
@@ -1353,7 +1538,11 @@ async function executeSqlForServices(sql, services, dataProductCoordinates, plug
1353
1538
  const safeSql = prepareSafeSql(sql, services);
1354
1539
  const isAccessPoint = services.some((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT);
1355
1540
  if (isAccessPoint && dataProductCoordinates) {
1356
- return plugin.executeLakehouseSql(safeSql, dataProductCoordinates, config);
1541
+ const result = await withTimeout(plugin.executeLakehouseSql(safeSql, dataProductCoordinates, config), AP_SQL_EXECUTION_TIMEOUT_MS);
1542
+ if (result === undefined) {
1543
+ throw new LegendAIExecutionTimeoutError(AP_SQL_EXECUTION_TIMEOUT_MESSAGE);
1544
+ }
1545
+ return result;
1357
1546
  }
1358
1547
  return plugin.executeSql(safeSql, config);
1359
1548
  }
@@ -1367,6 +1556,9 @@ async function executeSqlWithRetries(initialSql, question, services, coordinates
1367
1556
  }
1368
1557
  catch (executeError) {
1369
1558
  assertErrorThrown(executeError);
1559
+ if (executeError instanceof LegendAIExecutionTimeoutError) {
1560
+ return { sql: currentSql, error: executeError.message };
1561
+ }
1370
1562
  if (attempt >= DEFAULT_MAX_EXECUTION_RETRIES) {
1371
1563
  return { sql: currentSql, error: executeError.message };
1372
1564
  }
@@ -1466,7 +1658,10 @@ function buildZeroRowMessage(services) {
1466
1658
  const names = withFailedExtraction.map((s) => `**${s.title}**`).join(', ');
1467
1659
  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.`;
1468
1660
  }
1469
- 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.';
1661
+ return buildUnanswerableMessage();
1662
+ }
1663
+ function buildUnanswerableMessage() {
1664
+ 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.`;
1470
1665
  }
1471
1666
  function handleSqlGenerationFailure(setMessages, startTime, hasOrchestratorFallback, orchestratorMessage, errorMessage, errorType, suggestedQueries) {
1472
1667
  const suggestions = suggestedQueries ?? [];
@@ -1493,8 +1688,8 @@ function handleSqlGenerationFailure(setMessages, startTime, hasOrchestratorFallb
1493
1688
  }
1494
1689
  }
1495
1690
  async function reportQueryResults(report, metadata, context, startTime, hasOrchestratorFallback) {
1496
- const { currentSql, sqlResult, question, services } = report;
1497
- const { setMessages } = context;
1691
+ const { currentSql, sqlResult, question, services, allAccessPoints } = report;
1692
+ const { config, plugin, setMessages } = context;
1498
1693
  if (sqlResult.rows.length > 0) {
1499
1694
  const columns = deduplicateColumns(sqlResult.columns);
1500
1695
  const rows = sqlResult.rows;
@@ -1529,7 +1724,20 @@ async function reportQueryResults(report, metadata, context, startTime, hasOrche
1529
1724
  }
1530
1725
  else {
1531
1726
  addThinkingStep(setMessages, 'Query returned 0 rows after correction attempts.');
1727
+ const joinExplanation = buildCrossJoinZeroRowExplanation(currentSql, services);
1728
+ const joinSuggestions = joinExplanation === undefined
1729
+ ? []
1730
+ : buildJoinablePairSuggestions(services, allAccessPoints ?? services);
1731
+ const llmAnalysis = await withTimeout(plugin
1732
+ .buildNoResultsFallback(question, currentSql, metadata, config, joinExplanation)
1733
+ .catch((error) => {
1734
+ assertErrorThrown(error);
1735
+ return undefined;
1736
+ }), ANALYSIS_TIMEOUT_MS);
1532
1737
  completeThinkingSteps(setMessages);
1738
+ const suggestions = joinSuggestions.length > 0
1739
+ ? joinSuggestions
1740
+ : (llmAnalysis?.suggestedQueries ?? []);
1533
1741
  const fallback = hasOrchestratorFallback
1534
1742
  ? {
1535
1743
  fallbackAction: {
@@ -1539,7 +1747,10 @@ async function reportQueryResults(report, metadata, context, startTime, hasOrche
1539
1747
  }
1540
1748
  : {};
1541
1749
  updateLastAssistant(setMessages, () => ({
1542
- textAnswer: buildZeroRowMessage(services),
1750
+ textAnswer: llmAnalysis?.summary ??
1751
+ joinExplanation ??
1752
+ buildZeroRowMessage(services),
1753
+ ...(suggestions.length > 0 ? { suggestedQueries: suggestions } : {}),
1543
1754
  ...fallback,
1544
1755
  isProcessing: false,
1545
1756
  isExecuting: false,
@@ -1547,7 +1758,8 @@ async function reportQueryResults(report, metadata, context, startTime, hasOrche
1547
1758
  }));
1548
1759
  }
1549
1760
  }
1550
- export function supplementMissingCoverage(question, selected, allServices, maxTotal = 4) {
1761
+ const DEFAULT_MAX_SUPPLEMENTED_SERVICES = 4;
1762
+ export function supplementMissingCoverage(question, selected, allServices, maxTotal = DEFAULT_MAX_SUPPLEMENTED_SERVICES) {
1551
1763
  if (selected.length >= maxTotal || allServices.length <= selected.length) {
1552
1764
  return selected;
1553
1765
  }
@@ -1710,10 +1922,289 @@ async function selectBestServices(question, services, context) {
1710
1922
  return candidates;
1711
1923
  }
1712
1924
  }
1925
+ const EQUALITY_PREDICATE_PATTERN = /"(?<col>[^"]+)"\s*=\s*'(?<val>[^']*(?:''[^']*)*)'/gu;
1926
+ export const IN_PREDICATE_PATTERN = /"(?<col>[^"]+)"\s+in\s*\((?<list>[^)]+)\)/giu;
1927
+ export const IN_LIST_LITERAL_PATTERN = /'(?<val>[^']*(?:''[^']*)*)'/gu;
1928
+ const MIN_FUZZY_LITERAL_LENGTH = 3;
1929
+ function normalizeFilterLiteral(value) {
1930
+ return value.replaceAll(/[^a-z0-9]/giu, '').toUpperCase();
1931
+ }
1932
+ function escapeSqlStringLiteral(value) {
1933
+ return value.replaceAll("'", "''");
1934
+ }
1935
+ export function extractFromClause(sql) {
1936
+ const fromMatch = /\bfrom\b/iu.exec(sql);
1937
+ if (!fromMatch) {
1938
+ return undefined;
1939
+ }
1940
+ const rest = sql.slice(fromMatch.index);
1941
+ const afterFrom = rest.slice('from'.length);
1942
+ const boundary = /\b(?:where|group\s+by|order\s+by|having|limit)\b/iu.exec(afterFrom);
1943
+ return (boundary ? rest.slice(0, 'from'.length + boundary.index) : rest).trim();
1944
+ }
1945
+ // Extracts the WHERE clause segment (from the first `WHERE` up to the next
1946
+ // top-level clause boundary), so filter self-healing only rewrites WHERE
1947
+ // predicates — never JOIN ON keys or HAVING literals.
1948
+ export function extractWhereClause(sql) {
1949
+ const whereMatch = /\bwhere\b/iu.exec(sql);
1950
+ if (!whereMatch) {
1951
+ return undefined;
1952
+ }
1953
+ const rest = sql.slice(whereMatch.index);
1954
+ const afterWhere = rest.slice('where'.length);
1955
+ const boundary = /\b(?:group\s+by|order\s+by|having|limit|window|qualify)\b/iu.exec(afterWhere);
1956
+ return (boundary ? rest.slice(0, 'where'.length + boundary.index) : rest).trim();
1957
+ }
1958
+ export function resolveFilterLiteral(literal, values) {
1959
+ if (values.includes(literal)) {
1960
+ return undefined;
1961
+ }
1962
+ const caseInsensitive = Array.from(new Set(values.filter((v) => v.toLowerCase() === literal.toLowerCase())));
1963
+ if (caseInsensitive.length === 1) {
1964
+ return caseInsensitive[0];
1965
+ }
1966
+ const normalizedLiteral = normalizeFilterLiteral(literal);
1967
+ if (normalizedLiteral.length < MIN_FUZZY_LITERAL_LENGTH) {
1968
+ return undefined;
1969
+ }
1970
+ const normalizedExact = Array.from(new Set(values.filter((v) => normalizeFilterLiteral(v) === normalizedLiteral)));
1971
+ if (normalizedExact.length === 1) {
1972
+ return normalizedExact[0];
1973
+ }
1974
+ const prefixMatches = Array.from(new Set(values.filter((v) => {
1975
+ const normalizedValue = normalizeFilterLiteral(v);
1976
+ return (normalizedValue.length >= MIN_FUZZY_LITERAL_LENGTH &&
1977
+ (normalizedValue.startsWith(normalizedLiteral) ||
1978
+ normalizedLiteral.startsWith(normalizedValue)));
1979
+ })));
1980
+ return prefixMatches.length === 1 ? prefixMatches[0] : undefined;
1981
+ }
1982
+ // Rewrites `"col" = 'literal'` predicates in the WHERE clause whose literal
1983
+ // fuzzy-matches a real probed column value, returning the updated SQL and
1984
+ // whether anything changed. Every distinct predicate is corrected (a column can
1985
+ // appear with several literals across `OR` branches), and only the WHERE clause
1986
+ // is scanned so JOIN ON / HAVING literals are left untouched.
1987
+ async function correctEqualityFilters(sql, probeColumnValues, setMessages) {
1988
+ const whereClause = extractWhereClause(sql);
1989
+ if (whereClause === undefined) {
1990
+ return { sql, corrected: false };
1991
+ }
1992
+ const literalsByColumn = new Map();
1993
+ for (const match of whereClause.matchAll(EQUALITY_PREDICATE_PATTERN)) {
1994
+ const column = match.groups?.col;
1995
+ const literal = match.groups?.val;
1996
+ if (column === undefined || literal === undefined) {
1997
+ continue;
1998
+ }
1999
+ const literals = literalsByColumn.get(column) ?? new Set();
2000
+ literals.add(literal);
2001
+ literalsByColumn.set(column, literals);
2002
+ }
2003
+ const resolutions = new Map();
2004
+ for (const [column, literals] of literalsByColumn) {
2005
+ const values = await probeColumnValues(column);
2006
+ if (values === undefined) {
2007
+ continue;
2008
+ }
2009
+ for (const literal of literals) {
2010
+ const resolved = resolveFilterLiteral(literal, values);
2011
+ if (resolved !== undefined && resolved !== literal) {
2012
+ const columnResolutions = resolutions.get(column) ?? new Map();
2013
+ columnResolutions.set(literal, resolved);
2014
+ resolutions.set(column, columnResolutions);
2015
+ addThinkingStep(setMessages, `Matched "${column}" = '${literal}' to '${resolved}' from the column's values...`);
2016
+ }
2017
+ }
2018
+ }
2019
+ if (resolutions.size === 0) {
2020
+ return { sql, corrected: false };
2021
+ }
2022
+ const correctedWhere = whereClause.replace(EQUALITY_PREDICATE_PATTERN, (matched, column, literal) => {
2023
+ const resolved = resolutions.get(column)?.get(literal);
2024
+ return resolved === undefined
2025
+ ? matched
2026
+ : matched.replace(`'${literal}'`, () => `'${escapeSqlStringLiteral(resolved)}'`);
2027
+ });
2028
+ return {
2029
+ sql: sql.replace(whereClause, () => correctedWhere),
2030
+ corrected: true,
2031
+ };
2032
+ }
2033
+ // Rewrites the literals of a single `IN (...)` list to their fuzzy-matched
2034
+ // column values; returns the joined list text and whether anything changed.
2035
+ function rewriteInListLiterals(list, column, values, setMessages) {
2036
+ let changed = false;
2037
+ const rewrittenList = [];
2038
+ for (const literalMatch of list.matchAll(IN_LIST_LITERAL_PATTERN)) {
2039
+ const literal = literalMatch.groups?.val;
2040
+ if (literal === undefined) {
2041
+ continue;
2042
+ }
2043
+ const resolved = resolveFilterLiteral(literal, values);
2044
+ if (resolved !== undefined && resolved !== literal) {
2045
+ rewrittenList.push(`'${escapeSqlStringLiteral(resolved)}'`);
2046
+ changed = true;
2047
+ addThinkingStep(setMessages, `Matched "${column}" IN '${literal}' to '${resolved}' from the column's values...`);
2048
+ }
2049
+ else {
2050
+ rewrittenList.push(`'${literal}'`);
2051
+ }
2052
+ }
2053
+ return { rewritten: rewrittenList.join(', '), changed };
2054
+ }
2055
+ async function correctInPredicateFilters(sql, probeColumnValues, setMessages) {
2056
+ const whereClause = extractWhereClause(sql);
2057
+ if (whereClause === undefined) {
2058
+ return { sql, corrected: false };
2059
+ }
2060
+ const columns = new Set();
2061
+ for (const match of whereClause.matchAll(IN_PREDICATE_PATTERN)) {
2062
+ const column = match.groups?.col;
2063
+ if (column !== undefined) {
2064
+ columns.add(column);
2065
+ }
2066
+ }
2067
+ const valuesByColumn = new Map();
2068
+ for (const column of columns) {
2069
+ const values = await probeColumnValues(column);
2070
+ if (values !== undefined) {
2071
+ valuesByColumn.set(column, values);
2072
+ }
2073
+ }
2074
+ if (valuesByColumn.size === 0) {
2075
+ return { sql, corrected: false };
2076
+ }
2077
+ const correctedWhere = whereClause.replace(IN_PREDICATE_PATTERN, (matched, column, list) => {
2078
+ const values = valuesByColumn.get(column);
2079
+ if (values === undefined) {
2080
+ return matched;
2081
+ }
2082
+ const { rewritten, changed } = rewriteInListLiterals(list, column, values, setMessages);
2083
+ return changed && rewritten.length > 0
2084
+ ? matched.replace(list, () => rewritten)
2085
+ : matched;
2086
+ });
2087
+ return correctedWhere === whereClause
2088
+ ? { sql, corrected: false }
2089
+ : { sql: sql.replace(whereClause, () => correctedWhere), corrected: true };
2090
+ }
2091
+ // Distinct columns filtered by equality or IN predicates in the WHERE clause,
2092
+ // in encounter order — the set the zero-row recovery probes for real values.
2093
+ function collectWhereFilterColumns(sql) {
2094
+ const whereClause = extractWhereClause(sql);
2095
+ if (whereClause === undefined) {
2096
+ return [];
2097
+ }
2098
+ const columns = [];
2099
+ const seen = new Set();
2100
+ const addColumn = (column) => {
2101
+ if (column !== undefined && !seen.has(column)) {
2102
+ seen.add(column);
2103
+ columns.push(column);
2104
+ }
2105
+ };
2106
+ for (const match of whereClause.matchAll(EQUALITY_PREDICATE_PATTERN)) {
2107
+ addColumn(match.groups?.col);
2108
+ }
2109
+ for (const match of whereClause.matchAll(IN_PREDICATE_PATTERN)) {
2110
+ addColumn(match.groups?.col);
2111
+ }
2112
+ return columns;
2113
+ }
2114
+ // Indexes each access point by its lowercased column names (first writer wins),
2115
+ // so a filter column resolves to the access point that declares it.
2116
+ function buildAccessPointColumnIndex(services) {
2117
+ const index = new Map();
2118
+ for (const accessPoint of services) {
2119
+ if (accessPoint.sourceType !== TDSServiceSourceType.ACCESS_POINT) {
2120
+ continue;
2121
+ }
2122
+ for (const column of accessPoint.columns) {
2123
+ const key = column.name.toLowerCase();
2124
+ if (!index.has(key)) {
2125
+ index.set(key, accessPoint);
2126
+ }
2127
+ }
2128
+ }
2129
+ return index;
2130
+ }
2131
+ // Recovers a zero-row query by re-probing each WHERE-filter column's real distinct
2132
+ // values and rewriting equality/IN literals that don't match (casing/spelling).
2133
+ async function attemptDistinctValueRecovery(currentSql, selectedServices, dataProductCoordinates, context) {
2134
+ const { plugin, config, setMessages } = context;
2135
+ const isAccessPointProbe = dataProductCoordinates !== undefined &&
2136
+ selectedServices.some((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT);
2137
+ const accessPointByColumn = isAccessPointProbe
2138
+ ? buildAccessPointColumnIndex(selectedServices)
2139
+ : new Map();
2140
+ const fromClause = isAccessPointProbe
2141
+ ? undefined
2142
+ : extractFromClause(currentSql);
2143
+ if (!isAccessPointProbe && fromClause === undefined) {
2144
+ return undefined;
2145
+ }
2146
+ const probeAccessPointColumn = async (column) => {
2147
+ const accessPoint = accessPointByColumn.get(column.toLowerCase());
2148
+ const pId = accessPoint ? servicePId(accessPoint) : undefined;
2149
+ if (pId === undefined) {
2150
+ return { columns: [], rows: [] };
2151
+ }
2152
+ const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`;
2153
+ return plugin.executeLakehouseRelationQuery(relationQuery, guaranteeNonNullable(dataProductCoordinates), config);
2154
+ };
2155
+ const probedValues = new Map();
2156
+ const probeColumnValues = async (column) => {
2157
+ if (probedValues.has(column)) {
2158
+ return probedValues.get(column);
2159
+ }
2160
+ if (probedValues.size >= MAX_RECOVERED_FILTER_COLUMNS) {
2161
+ return undefined;
2162
+ }
2163
+ let values;
2164
+ try {
2165
+ const probe = isAccessPointProbe
2166
+ ? await probeAccessPointColumn(column)
2167
+ : await executeSqlForServices(`SELECT DISTINCT "${column}" ${fromClause} LIMIT ${DISTINCT_PROBE_ROW_LIMIT}`, selectedServices, dataProductCoordinates, plugin, config);
2168
+ values = probe.rows
2169
+ .map((row) => row[column])
2170
+ .filter(isNonNullable)
2171
+ .map(String);
2172
+ }
2173
+ catch (probeError) {
2174
+ assertErrorThrown(probeError);
2175
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
2176
+ values = undefined;
2177
+ }
2178
+ probedValues.set(column, values);
2179
+ return values;
2180
+ };
2181
+ await Promise.all(collectWhereFilterColumns(currentSql)
2182
+ .slice(0, MAX_RECOVERED_FILTER_COLUMNS)
2183
+ .map((column) => probeColumnValues(column)));
2184
+ const equalityPass = await correctEqualityFilters(currentSql, probeColumnValues, setMessages);
2185
+ const inPass = await correctInPredicateFilters(equalityPass.sql, probeColumnValues, setMessages);
2186
+ const correctedSql = inPass.sql;
2187
+ const corrected = equalityPass.corrected || inPass.corrected;
2188
+ if (!corrected) {
2189
+ return undefined;
2190
+ }
2191
+ try {
2192
+ const retryResult = await executeSqlForServices(correctedSql, selectedServices, dataProductCoordinates, plugin, config);
2193
+ if (retryResult.rows.length > 0) {
2194
+ return { sql: correctedSql, result: retryResult };
2195
+ }
2196
+ }
2197
+ catch (retryError) {
2198
+ assertErrorThrown(retryError);
2199
+ addThinkingStep(setMessages, 'Filter-recovery retry failed');
2200
+ }
2201
+ return undefined;
2202
+ }
1713
2203
  async function tryRecoverZeroRows(currentSql, sqlResult, question, selectedServices, coordinates, dataProductCoordinates, context) {
1714
2204
  const { plugin, config, setMessages } = context;
1715
2205
  let recoveredSql = currentSql;
1716
2206
  let recoveredResult = sqlResult;
2207
+ let selfHealed = LegendAISelfHealKind.NONE;
1717
2208
  const strippedSql = stripGuessedNonDateServiceParams(recoveredSql, question);
1718
2209
  if (strippedSql !== recoveredSql) {
1719
2210
  addThinkingStep(setMessages, 'Trying query without guessed parameter values...');
@@ -1730,14 +2221,25 @@ async function tryRecoverZeroRows(currentSql, sqlResult, question, selectedServi
1730
2221
  addThinkingStep(setMessages, `Parameter stripping recovery failed: ${stripError.message.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
1731
2222
  }
1732
2223
  }
2224
+ if (recoveredResult.rows.length === 0) {
2225
+ addThinkingStep(setMessages, "Checking filter values against the column's actual values...");
2226
+ const resolved = await attemptDistinctValueRecovery(recoveredSql, selectedServices, dataProductCoordinates, context);
2227
+ if (resolved) {
2228
+ recoveredSql = resolved.sql;
2229
+ recoveredResult = resolved.result;
2230
+ selfHealed = LegendAISelfHealKind.DISTINCT_VALUE;
2231
+ updateLastAssistant(setMessages, () => ({ sql: resolved.sql }));
2232
+ }
2233
+ }
1733
2234
  if (recoveredResult.rows.length === 0) {
1734
2235
  const correction = await attemptZeroRowCorrection(recoveredSql, question, selectedServices, coordinates, dataProductCoordinates, context);
1735
2236
  if (correction) {
1736
2237
  recoveredSql = correction.sql;
1737
2238
  recoveredResult = correction.result;
2239
+ selfHealed = LegendAISelfHealKind.LLM_ZERO_ROW;
1738
2240
  }
1739
2241
  }
1740
- return { sql: recoveredSql, result: recoveredResult };
2242
+ return { sql: recoveredSql, result: recoveredResult, selfHealed };
1741
2243
  }
1742
2244
  async function resolveNestedPCalls(sql, question, selectedAPs, context, modelContextEnrichment) {
1743
2245
  if (!hasNestedPCalls(sql)) {
@@ -1797,11 +2299,64 @@ async function retryAccessPointSqlFix(options, context, metadata) {
1797
2299
  * - Uses AP-specific generator/judge prompts (p() syntax, no coordinates/params)
1798
2300
  * - Skips parameter detection (APs have no parameters)
1799
2301
  */
2302
+ // Handles a failed access-point SQL execution: attempts an SQL-fix retry for
2303
+ // fixable errors, otherwise reports the execution error with fallback suggestions.
2304
+ async function handleAccessPointExecutionError(executeError, params, context) {
2305
+ const { setMessages } = context;
2306
+ const { finalSql, question, selectedAPs, dataProductCoordinates, modelContextEnrichment, startTime, execStartTime, metadata, } = params;
2307
+ const execErrorType = classifyError(executeError);
2308
+ const errMsg = executeError.message;
2309
+ const errorCategory = categorizeExecutionError(errMsg, executeError);
2310
+ addThinkingStep(setMessages, `Execution failed: ${errMsg.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
2311
+ if (errorCategory === ExecutionErrorCategory.SQL_FIXABLE) {
2312
+ const retried = await retryAccessPointSqlFix({
2313
+ failedSql: finalSql,
2314
+ question,
2315
+ errMsg,
2316
+ selectedAPs,
2317
+ dataProductCoordinates,
2318
+ ...(modelContextEnrichment === undefined
2319
+ ? {}
2320
+ : { modelContextEnrichment }),
2321
+ startTime,
2322
+ }, context, metadata);
2323
+ if (retried) {
2324
+ return;
2325
+ }
2326
+ }
2327
+ finishWithThinkingError(setMessages, buildExecutionErrorMessage(errMsg, selectedAPs), startTime, execErrorType === LegendAIErrorType.GENERAL
2328
+ ? LegendAIErrorType.EXECUTION
2329
+ : execErrorType);
2330
+ const queriedGroups = [
2331
+ ...new Set(selectedAPs
2332
+ .map((ap) => ap.accessPointGroupTitle)
2333
+ .filter((t) => t !== undefined)),
2334
+ ];
2335
+ updateLastAssistant(setMessages, () => ({
2336
+ execTime: elapsedSeconds(execStartTime, 2),
2337
+ isExecuting: false,
2338
+ suggestedQueries: buildFallbackSuggestions(selectedAPs),
2339
+ queriedAccessPointGroups: queriedGroups,
2340
+ }));
2341
+ }
1800
2342
  async function processAccessPointQuery(question, accessPoints, metadata, context, startTime, dataProductCoordinates, modelContextEnrichment) {
1801
2343
  const { config, plugin, setMessages } = context;
1802
2344
  addThinkingStep(setMessages, 'Found relevant access points to query');
1803
2345
  const selectedAPs = await selectBestServices(question, accessPoints, context);
1804
- const judgedSql = await generateAndJudgeAccessPointSql(question, selectedAPs, context, startTime, modelContextEnrichment);
2346
+ const currentAccessPoints = new Set(selectedAPs.map((ap) => ap.pattern.replace(/^\//, '')));
2347
+ const scopedContext = {
2348
+ ...context,
2349
+ history: filterHistoryForAccessPoints(context.history, currentAccessPoints),
2350
+ };
2351
+ try {
2352
+ addThinkingStep(setMessages, 'Grounding filter values from real data...');
2353
+ await withTimeout(plugin.enrichAccessPointSampleValues(selectedAPs, dataProductCoordinates, config), VALUE_GROUNDING_TIME_BUDGET_MS);
2354
+ }
2355
+ catch (error) {
2356
+ assertErrorThrown(error);
2357
+ addThinkingStep(setMessages, 'Value grounding skipped');
2358
+ }
2359
+ const judgedSql = await generateAndJudgeAccessPointSql(question, selectedAPs, scopedContext, startTime, modelContextEnrichment);
1805
2360
  if (!judgedSql) {
1806
2361
  addThinkingStep(setMessages, 'SQL generation could not produce a valid query.');
1807
2362
  handleSqlGenerationFailure(setMessages, startTime, false, SQL_GENERATION_FAILURE_WITH_ORCHESTRATOR, SQL_GENERATION_FAILURE_NO_ORCHESTRATOR, LegendAIErrorType.GENERATION, buildFallbackSuggestions(selectedAPs));
@@ -1809,61 +2364,51 @@ async function processAccessPointQuery(question, accessPoints, metadata, context
1809
2364
  }
1810
2365
  const sqlGenTimeValue = elapsedSeconds(startTime, 2);
1811
2366
  completeThinkingSteps(setMessages);
1812
- const finalSql = await resolveNestedPCalls(judgedSql, question, selectedAPs, context, modelContextEnrichment);
2367
+ const finalSql = await resolveNestedPCalls(judgedSql, question, selectedAPs, scopedContext, modelContextEnrichment);
1813
2368
  updateLastAssistant(setMessages, () => ({
1814
2369
  sql: finalSql,
1815
2370
  sqlGenTime: sqlGenTimeValue,
1816
2371
  isExecuting: true,
2372
+ queriedAccessPoints: orderedAccessPointsFromSql(finalSql),
1817
2373
  }));
1818
2374
  const execStartTime = Date.now();
1819
2375
  try {
1820
- const rawResult = await executeSqlForServices(finalSql, selectedAPs, dataProductCoordinates, plugin, config);
2376
+ let execSql = finalSql;
2377
+ let rawResult = await executeSqlForServices(execSql, selectedAPs, dataProductCoordinates, plugin, config);
2378
+ if (rawResult.rows.length === 0) {
2379
+ addThinkingStep(setMessages, "Checking filter values against the column's actual values...");
2380
+ const resolved = await attemptDistinctValueRecovery(execSql, selectedAPs, dataProductCoordinates, scopedContext);
2381
+ if (resolved) {
2382
+ execSql = resolved.sql;
2383
+ rawResult = resolved.result;
2384
+ updateLastAssistant(setMessages, () => ({ sql: execSql }));
2385
+ }
2386
+ }
1821
2387
  await reportQueryResults({
1822
- currentSql: finalSql,
2388
+ currentSql: execSql,
1823
2389
  sqlResult: rawResult,
1824
2390
  question,
1825
2391
  services: selectedAPs,
1826
- }, metadata, context, startTime, false);
2392
+ allAccessPoints: accessPoints,
2393
+ }, metadata, scopedContext, startTime, false);
1827
2394
  }
1828
2395
  catch (executeError) {
1829
2396
  assertErrorThrown(executeError);
1830
- const execErrorType = classifyError(executeError);
1831
- const errMsg = executeError.message;
1832
- const errorCategory = categorizeExecutionError(errMsg, executeError);
1833
- addThinkingStep(setMessages, `Execution failed: ${errMsg.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
1834
- if (errorCategory === ExecutionErrorCategory.SQL_FIXABLE) {
1835
- const retried = await retryAccessPointSqlFix({
1836
- failedSql: finalSql,
1837
- question,
1838
- errMsg,
1839
- selectedAPs,
1840
- dataProductCoordinates,
1841
- ...(modelContextEnrichment === undefined
1842
- ? {}
1843
- : { modelContextEnrichment }),
1844
- startTime,
1845
- }, context, metadata);
1846
- if (retried) {
1847
- return;
1848
- }
1849
- }
1850
- finishWithThinkingError(setMessages, buildExecutionErrorMessage(errMsg, selectedAPs), startTime, execErrorType === LegendAIErrorType.GENERAL
1851
- ? LegendAIErrorType.EXECUTION
1852
- : execErrorType);
1853
- const queriedGroups = [
1854
- ...new Set(selectedAPs
1855
- .map((ap) => ap.accessPointGroupTitle)
1856
- .filter((t) => t !== undefined)),
1857
- ];
1858
- updateLastAssistant(setMessages, () => ({
1859
- execTime: elapsedSeconds(execStartTime, 2),
1860
- isExecuting: false,
1861
- suggestedQueries: buildFallbackSuggestions(selectedAPs),
1862
- queriedAccessPointGroups: queriedGroups,
1863
- }));
1864
- }
1865
- }
1866
- async function processDataQuery(question, services, coordinates, metadata, context, startTime, orchestratorOptions, modelContextEnrichment) {
2397
+ await handleAccessPointExecutionError(executeError, {
2398
+ finalSql,
2399
+ question,
2400
+ selectedAPs,
2401
+ dataProductCoordinates,
2402
+ ...(modelContextEnrichment === undefined
2403
+ ? {}
2404
+ : { modelContextEnrichment }),
2405
+ startTime,
2406
+ execStartTime,
2407
+ metadata,
2408
+ }, scopedContext);
2409
+ }
2410
+ }
2411
+ async function processDataQuery(question, services, coordinates, metadata, context, startTime, orchestratorOptions, modelContextEnrichment, approachText) {
1867
2412
  const { config, setMessages } = context;
1868
2413
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
1869
2414
  const hasOrchestratorFallback = Boolean(config.orchestratorUrl && dataProductCoordinates);
@@ -1940,6 +2485,9 @@ async function processDataQuery(question, services, coordinates, metadata, conte
1940
2485
  question,
1941
2486
  services: selectedServices,
1942
2487
  }, metadata, context, startTime, hasOrchestratorFallback);
2488
+ if (approachText) {
2489
+ attachMetadataOverview(setMessages, approachText);
2490
+ }
1943
2491
  }
1944
2492
  function splitServicesByType(services) {
1945
2493
  return {
@@ -1947,7 +2495,7 @@ function splitServicesByType(services) {
1947
2495
  accessPoints: services.filter((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT),
1948
2496
  };
1949
2497
  }
1950
- async function routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOpts, modelContextEnrichment) {
2498
+ async function routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOpts, modelContextEnrichment, approachText) {
1951
2499
  const dataProductCoordinates = orchestratorOpts?.dataProductCoordinates;
1952
2500
  const { tdsServices, accessPoints } = splitServicesByType(services);
1953
2501
  if (tdsServices.length === 0 &&
@@ -1956,15 +2504,37 @@ async function routeToAccessPointOrData(question, services, coordinates, metadat
1956
2504
  await processAccessPointQuery(question, accessPoints, metadata, context, startTime, dataProductCoordinates, modelContextEnrichment);
1957
2505
  }
1958
2506
  else {
1959
- await processDataQuery(question, tdsServices, coordinates, metadata, context, startTime, orchestratorOpts, modelContextEnrichment);
2507
+ await processDataQuery(question, tdsServices, coordinates, metadata, context, startTime, orchestratorOpts, modelContextEnrichment, approachText);
1960
2508
  }
1961
2509
  }
2510
+ /**
2511
+ * Compute a value on first use and cache it. The data-query and metadata
2512
+ * answer paths each need a different (potentially large) model-context string,
2513
+ * but only one path runs per question. Deferring the build means we never pay
2514
+ * to construct the string the chosen path doesn't consume, and the build
2515
+ * happens inside the caller's try/catch rather than eagerly at entry.
2516
+ */
2517
+ function lazyMemo(compute) {
2518
+ let computed = false;
2519
+ let value;
2520
+ return () => {
2521
+ if (!computed) {
2522
+ value = compute();
2523
+ computed = true;
2524
+ }
2525
+ return value;
2526
+ };
2527
+ }
1962
2528
  export async function processQuestion(question, services, coordinates, metadata, context, dataProductCoordinates, pureExecutionContext, modelContext) {
1963
2529
  const { config, plugin, setMessages } = context;
1964
2530
  const startTime = Date.now();
1965
- const modelContextEnrichment = modelContext
2531
+ const getModelContextEnrichment = lazyMemo(() => modelContext
1966
2532
  ? buildModelContextEnrichmentText(modelContext, services)
1967
- : undefined;
2533
+ : undefined);
2534
+ const getMetadataEnrichment = lazyMemo(() => modelContext ? buildModelCatalogText(modelContext) : undefined);
2535
+ const getApproachText = lazyMemo(() => modelContext
2536
+ ? buildDataQueryApproachText(question, modelContext)
2537
+ : undefined);
1968
2538
  try {
1969
2539
  addThinkingStep(setMessages, 'Analyzing your question...');
1970
2540
  const orchestratorOpts = dataProductCoordinates
@@ -1976,7 +2546,7 @@ export async function processQuestion(question, services, coordinates, metadata,
1976
2546
  }
1977
2547
  : undefined;
1978
2548
  if (services.length === 0) {
1979
- await handleMetadataQuestion(question, metadata, context, startTime, false, services, modelContextEnrichment);
2549
+ await handleMetadataQuestion(question, metadata, context, startTime, false, services, getMetadataEnrichment());
1980
2550
  if (config.orchestratorUrl && dataProductCoordinates) {
1981
2551
  updateLastAssistant(setMessages, () => ({
1982
2552
  fallbackAction: {
@@ -1990,16 +2560,16 @@ export async function processQuestion(question, services, coordinates, metadata,
1990
2560
  const serviceNames = services.map((s) => s.title);
1991
2561
  const intent = await plugin.classifyQuestionIntent(question, true, config, serviceNames);
1992
2562
  if (intent === LegendAIQuestionIntent.METADATA) {
1993
- await handleMetadataQuestion(question, metadata, context, startTime, true, services, modelContextEnrichment);
2563
+ await handleMetadataQuestion(question, metadata, context, startTime, true, services, getMetadataEnrichment());
1994
2564
  return;
1995
2565
  }
1996
2566
  try {
1997
- await routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOpts, modelContextEnrichment);
2567
+ await routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOpts, getModelContextEnrichment(), getApproachText());
1998
2568
  }
1999
2569
  catch (sqlError) {
2000
2570
  assertErrorThrown(sqlError);
2001
2571
  addThinkingStep(setMessages, 'Query failed, answering from product metadata...');
2002
- await handleMetadataQuestion(question, metadata, context, startTime, true, services, modelContextEnrichment);
2572
+ await handleMetadataQuestion(question, metadata, context, startTime, true, services, getMetadataEnrichment());
2003
2573
  appendFallbackSuggestions(setMessages, services);
2004
2574
  }
2005
2575
  }
@@ -2010,12 +2580,12 @@ export async function processQuestion(question, services, coordinates, metadata,
2010
2580
  appendFallbackSuggestions(setMessages, services);
2011
2581
  }
2012
2582
  }
2013
- async function routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, modelContextEnrichment) {
2583
+ async function routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, modelContextEnrichment, approachText) {
2014
2584
  const { setMessages } = context;
2015
2585
  const startTime = Date.now();
2016
2586
  try {
2017
2587
  addThinkingStep(setMessages, 'Preparing data query...');
2018
- await routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOptions, modelContextEnrichment);
2588
+ await routeToAccessPointOrData(question, services, coordinates, metadata, context, startTime, orchestratorOptions, modelContextEnrichment, approachText);
2019
2589
  }
2020
2590
  catch (error) {
2021
2591
  assertErrorThrown(error);
@@ -2026,13 +2596,17 @@ async function routeDataQueryWithErrorHandling(question, services, coordinates,
2026
2596
  export async function processQuestionWithIntent(question, intent, services, coordinates, metadata, context, orchestratorOptions, modelContext) {
2027
2597
  const { config, setMessages } = context;
2028
2598
  const dataProductCoordinates = orchestratorOptions?.dataProductCoordinates;
2029
- const modelContextEnrichment = modelContext
2599
+ const getModelContextEnrichment = lazyMemo(() => modelContext
2030
2600
  ? buildModelContextEnrichmentText(modelContext, services)
2031
- : undefined;
2601
+ : undefined);
2602
+ const getMetadataEnrichment = lazyMemo(() => modelContext ? buildModelCatalogText(modelContext) : undefined);
2603
+ const getApproachText = lazyMemo(() => modelContext
2604
+ ? buildDataQueryApproachText(question, modelContext)
2605
+ : undefined);
2032
2606
  if (intent === LegendAIQuestionIntent.METADATA) {
2033
2607
  const startTime = Date.now();
2034
2608
  try {
2035
- await handleMetadataQuestion(question, metadata, context, startTime, services.length > 0, services, modelContextEnrichment);
2609
+ await handleMetadataQuestion(question, metadata, context, startTime, services.length > 0, services, getMetadataEnrichment());
2036
2610
  }
2037
2611
  catch (error) {
2038
2612
  assertErrorThrown(error);
@@ -2045,11 +2619,11 @@ export async function processQuestionWithIntent(question, intent, services, coor
2045
2619
  config.orchestratorUrl &&
2046
2620
  dataProductCoordinates) {
2047
2621
  if (services.length > 0) {
2048
- await routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, modelContextEnrichment);
2622
+ await routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, getModelContextEnrichment(), getApproachText());
2049
2623
  return;
2050
2624
  }
2051
2625
  const startTime = Date.now();
2052
- await handleMetadataQuestion(question, metadata, context, startTime, false, services, modelContextEnrichment);
2626
+ await handleMetadataQuestion(question, metadata, context, startTime, false, services, getMetadataEnrichment());
2053
2627
  updateLastAssistant(setMessages, () => ({
2054
2628
  fallbackAction: {
2055
2629
  label: ORCHESTRATOR_FALLBACK_LABEL,
@@ -2058,6 +2632,6 @@ export async function processQuestionWithIntent(question, intent, services, coor
2058
2632
  }));
2059
2633
  return;
2060
2634
  }
2061
- await routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, modelContextEnrichment);
2635
+ await routeDataQueryWithErrorHandling(question, services, coordinates, metadata, context, orchestratorOptions, getModelContextEnrichment(), getApproachText());
2062
2636
  }
2063
2637
  //# sourceMappingURL=LegendAIChatProcessors.js.map