@finos/legend-lego 2.0.217 → 2.0.218

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 (50) 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 +14 -0
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +140 -17
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAITypes.d.ts +30 -1
  8. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  9. package/lib/legend-ai/LegendAITypes.js +31 -0
  10. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  11. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +2 -2
  12. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  13. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +1 -1
  14. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  15. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  16. package/lib/legend-ai/components/LegendAIChat.js +62 -38
  17. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  18. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +5 -0
  19. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
  20. package/lib/legend-ai/components/LegendAIChatHelpers.js +8 -1
  21. package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
  22. package/lib/legend-ai/index.d.ts +3 -2
  23. package/lib/legend-ai/index.d.ts.map +1 -1
  24. package/lib/legend-ai/index.js +3 -2
  25. package/lib/legend-ai/index.js.map +1 -1
  26. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +27 -2
  27. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  28. package/lib/legend-ai/stores/LegendAIChatProcessors.js +329 -86
  29. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  30. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +11 -0
  31. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -1
  32. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +53 -19
  33. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -1
  34. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +4 -1
  35. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -1
  36. package/lib/legend-ai/stores/LegendAISqlHelpers.js +25 -2
  37. package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -1
  38. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +4 -4
  39. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/legend-ai/LegendAIDocEnrichment.ts +189 -23
  42. package/src/legend-ai/LegendAITypes.ts +54 -1
  43. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +2 -0
  44. package/src/legend-ai/components/LegendAIChat.tsx +114 -78
  45. package/src/legend-ai/components/LegendAIChatHelpers.ts +9 -3
  46. package/src/legend-ai/index.ts +7 -0
  47. package/src/legend-ai/stores/LegendAIChatProcessors.ts +476 -105
  48. package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +82 -19
  49. package/src/legend-ai/stores/LegendAISqlHelpers.ts +32 -2
  50. package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +4 -4
@@ -22,6 +22,8 @@ import {
22
22
  uuid,
23
23
  } from '@finos/legend-shared';
24
24
  import {
25
+ type LegendAIFallbackAction,
26
+ type LegendAIPriorSqlFailure,
25
27
  type TDSServiceSchema,
26
28
  type LegendAIConfig,
27
29
  type LegendAIAssistantMessage,
@@ -68,6 +70,7 @@ import {
68
70
  relaxExactStringFilters,
69
71
  extractFilteredColumns,
70
72
  buildProbedValueHints,
73
+ buildPriorSqlFailureHints,
71
74
  splitIdentifierTokens,
72
75
  tokenizeText,
73
76
  } from '../LegendAIDocEnrichment.js';
@@ -78,16 +81,25 @@ import {
78
81
  import {
79
82
  isNumericColumn,
80
83
  isStringColumn,
84
+ isStringTypedColumn,
81
85
  } from '../components/LegendAIChatHelpers.js';
82
86
  import {
87
+ buildColumnByNameIndex,
88
+ accessPointName,
83
89
  HAS_LIMIT_PATTERN,
84
- AP_CALL_PATTERN,
90
+ accessPointCalls,
85
91
  servicePId,
86
92
  pureRelationColumnRef,
93
+ extractJoinKeyColumns,
94
+ sharedColumnNames,
87
95
  } from './LegendAISqlHelpers.js';
88
96
  import {
89
97
  buildCrossJoinZeroRowExplanation,
90
98
  buildJoinablePairSuggestions,
99
+ detectDisjointJoinUniverses,
100
+ resolveJoinedAccessPoints,
101
+ buildDisjointJoinMessage,
102
+ previewValues,
91
103
  } from './LegendAIJoinAnalysis.js';
92
104
  import {
93
105
  boundCrossAccessPointJoinDrivingSide,
@@ -106,6 +118,10 @@ const ANALYSIS_TIMEOUT_MS = 15_000;
106
118
  const ORCHESTRATOR_GENERATION_TIMEOUT_MS = 120_000;
107
119
  const DISTINCT_PROBE_ROW_LIMIT = 5000;
108
120
  const MAX_RECOVERED_FILTER_COLUMNS = 4;
121
+ export const JOIN_OVERLAP_PROBE_LIMIT = 200;
122
+ const JOIN_OVERLAP_CONCLUSIVE_MAX = JOIN_OVERLAP_PROBE_LIMIT - 1;
123
+ const MAX_JOIN_OVERLAP_PROBE_KEYS = 4;
124
+ const JOIN_OVERLAP_PROBE_TIMEOUT_MS = 15_000;
109
125
  const EXECUTION_TIMEOUT_MS = 300_000;
110
126
  const ANALYSIS_PREVIEW_ROW_LIMIT = 3;
111
127
  const ANALYSIS_PREVIEW_VALUE_LIMIT = 40;
@@ -117,12 +133,32 @@ const SERVICE_CALL_PATTERN = /\bservice\s*\([^()]*\)/gi;
117
133
  const DEFAULT_SAFETY_LIMIT = 1000;
118
134
  const HAS_AGGREGATION_PATTERN =
119
135
  /\bGROUP\s+BY\b|\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(|\bSELECT\s+DISTINCT\b|\bHAVING\b|\bQUALIFY\b/i;
136
+ const OUTER_JOIN_PATTERN = /\b(?:LEFT|RIGHT|FULL)\s+(?:OUTER\s+)?JOIN\b/i;
137
+ const DISJUNCTIVE_PREDICATE_PATTERN = /\bOR\b/i;
138
+ const SET_OPERATION_PATTERN = /\b(?:UNION|INTERSECT|EXCEPT)\b/i;
120
139
  const MAX_SERVICES_FOR_LLM_SELECTION = 30;
121
140
  const VALUE_GROUNDING_TIME_BUDGET_MS = 4000;
122
141
  const AP_SQL_EXECUTION_TIMEOUT_MS = 90_000;
123
142
  const AP_SQL_EXECUTION_TIMEOUT_MESSAGE =
124
143
  '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.';
125
144
  const ORCHESTRATOR_FALLBACK_LABEL = 'Try Legend AI Orchestrator';
145
+
146
+ // The single orchestrator fallback action, so every dead-end path offers the
147
+ // same button and threads any prior SQL failure the same way.
148
+ export function buildOrchestratorFallbackAction(
149
+ failure?: LegendAIPriorSqlFailure,
150
+ ): LegendAIFallbackAction {
151
+ return {
152
+ label: ORCHESTRATOR_FALLBACK_LABEL,
153
+ actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
154
+ ...(failure?.failedSql === undefined
155
+ ? {}
156
+ : { failedSql: failure.failedSql }),
157
+ ...(failure?.failedReason === undefined
158
+ ? {}
159
+ : { failedReason: failure.failedReason }),
160
+ };
161
+ }
126
162
  const SQL_GENERATION_FAILURE_WITH_ORCHESTRATOR =
127
163
  'SQL generation could not handle this query. You can try the Legend AI Orchestrator to generate a Pure query instead.';
128
164
  const SQL_GENERATION_FAILURE_NO_ORCHESTRATOR =
@@ -186,31 +222,35 @@ export type MessageSetter = React.Dispatch<
186
222
  React.SetStateAction<LegendAIMessage[]>
187
223
  >;
188
224
 
225
+ export function createAssistantMessage(): LegendAIAssistantMessage {
226
+ return {
227
+ id: uuid(),
228
+ role: LegendAIMessageRole.ASSISTANT,
229
+ thinkingSteps: [],
230
+ sql: null,
231
+ textAnswer: null,
232
+ dataContext: null,
233
+ gridData: null,
234
+ error: null,
235
+ errorType: null,
236
+ sqlGenTime: null,
237
+ execTime: null,
238
+ thinkingDuration: null,
239
+ isProcessing: true,
240
+ isExecuting: false,
241
+ suggestedQueries: [],
242
+ fallbackAction: null,
243
+ queriedAccessPointGroups: [],
244
+ queriedAccessPoints: [],
245
+ };
246
+ }
247
+
189
248
  export function createMessagePair(
190
249
  text: string,
191
250
  ): [LegendAIUserMessage, LegendAIAssistantMessage] {
192
251
  return [
193
252
  { id: uuid(), role: LegendAIMessageRole.USER, text },
194
- {
195
- id: uuid(),
196
- role: LegendAIMessageRole.ASSISTANT,
197
- thinkingSteps: [],
198
- sql: null,
199
- textAnswer: null,
200
- dataContext: null,
201
- gridData: null,
202
- error: null,
203
- errorType: null,
204
- sqlGenTime: null,
205
- execTime: null,
206
- thinkingDuration: null,
207
- isProcessing: true,
208
- isExecuting: false,
209
- suggestedQueries: [],
210
- fallbackAction: null,
211
- queriedAccessPointGroups: [],
212
- queriedAccessPoints: [],
213
- },
253
+ createAssistantMessage(),
214
254
  ];
215
255
  }
216
256
 
@@ -385,7 +425,7 @@ export function filterHistoryForAccessPoints(
385
425
  function orderedAccessPointsFromSql(sql: string): string[] {
386
426
  const ordered: string[] = [];
387
427
  const seen = new Set<string>();
388
- for (const match of sql.matchAll(AP_CALL_PATTERN)) {
428
+ for (const match of accessPointCalls(sql)) {
389
429
  const path = match.groups?.pId ?? '';
390
430
  const dotIdx = path.lastIndexOf('.');
391
431
  const apName = dotIdx === -1 ? path : path.slice(dotIdx + 1);
@@ -397,6 +437,9 @@ function orderedAccessPointsFromSql(sql: string): string[] {
397
437
  return ordered;
398
438
  }
399
439
 
440
+ const MAX_HISTORY_ANSWER_LENGTH = 1500;
441
+ const MAX_CONVERSATION_HISTORY_TURNS = 10;
442
+
400
443
  function buildTurnFromAssistant(
401
444
  userText: string,
402
445
  asstMsg: LegendAIAssistantMessage,
@@ -426,7 +469,7 @@ function buildTurnFromAssistant(
426
469
  if (asstMsg.textAnswer) {
427
470
  return {
428
471
  question: userText,
429
- sql: asstMsg.textAnswer,
472
+ sql: asstMsg.textAnswer.slice(0, MAX_HISTORY_ANSWER_LENGTH),
430
473
  intent: LegendAIQuestionIntent.METADATA,
431
474
  };
432
475
  }
@@ -465,6 +508,10 @@ export function classifyResponseOutcome(
465
508
  return LegendAIResponseOutcome.NO_ANSWER;
466
509
  }
467
510
 
511
+ /**
512
+ * Pairs each question with the assistant turn that answered it, keeping only
513
+ * the most recent turns so prompts do not grow with conversation length.
514
+ */
468
515
  export function buildConversationHistory(
469
516
  messages: LegendAIMessage[],
470
517
  ): LegendAIConversationTurn[] {
@@ -486,7 +533,7 @@ export function buildConversationHistory(
486
533
  i += 1;
487
534
  }
488
535
  }
489
- return history;
536
+ return history.slice(-MAX_CONVERSATION_HISTORY_TURNS);
490
537
  }
491
538
 
492
539
  function formatServiceParams(services: TDSServiceSchema[]): string[] {
@@ -968,6 +1015,118 @@ export function attachMetadataOverview(
968
1015
  });
969
1016
  }
970
1017
 
1018
+ const PRODUCT_OVERVIEW_PATTERNS: readonly RegExp[] = [
1019
+ /\bwhat\s+does\b.*\b(?:offer|provide|contain|have|include|cover)\b/,
1020
+ /\b(?:describe|summarize|overview|summary)\b/,
1021
+ /\bwhat\s+(?:access\s*points?|services?|data|datasets?)\b/,
1022
+ /\bwhat\s+can\s+(?:i|we|you)\b/,
1023
+ /\btell\s+me\s+(?:about|more)\b/,
1024
+ ];
1025
+
1026
+ const LISTING_VERB_PATTERN = /\b(?:list|show|what\s+are)\b/;
1027
+ const LISTING_NOUN_PATTERN =
1028
+ /\b(?:access\s*points?|services?|endpoints?|datasets?|tables?)\b/;
1029
+
1030
+ export function isProductOverviewQuestion(question: string): boolean {
1031
+ const q = question.toLowerCase();
1032
+ return (
1033
+ PRODUCT_OVERVIEW_PATTERNS.some((pattern) => pattern.test(q)) ||
1034
+ (LISTING_VERB_PATTERN.test(q) && LISTING_NOUN_PATTERN.test(q))
1035
+ );
1036
+ }
1037
+
1038
+ const CATALOG_MIN_SERVICES = 10;
1039
+ const CATALOG_MAX_ENTRIES = 200;
1040
+
1041
+ // Buckets services under their access point group, groups in title order.
1042
+ function groupServicesByAccessPointGroup(
1043
+ services: TDSServiceSchema[],
1044
+ ): [string, TDSServiceSchema[]][] {
1045
+ const byGroup = new Map<string, TDSServiceSchema[]>();
1046
+ for (const svc of services) {
1047
+ const group = svc.accessPointGroupTitle ?? '';
1048
+ const existing = byGroup.get(group);
1049
+ if (existing) {
1050
+ existing.push(svc);
1051
+ } else {
1052
+ byGroup.set(group, [svc]);
1053
+ }
1054
+ }
1055
+ return Array.from(byGroup.entries()).sort((a, b) => a[0].localeCompare(b[0]));
1056
+ }
1057
+
1058
+ // Renders each group as a heading followed by its services, up to the cap.
1059
+ function renderCatalogEntries(groups: [string, TDSServiceSchema[]][]): {
1060
+ lines: string[];
1061
+ rendered: number;
1062
+ } {
1063
+ const lines: string[] = [];
1064
+ let rendered = 0;
1065
+ for (const [group, entries] of groups) {
1066
+ if (rendered >= CATALOG_MAX_ENTRIES) {
1067
+ break;
1068
+ }
1069
+ if (group) {
1070
+ lines.push(`\n### ${group}`);
1071
+ }
1072
+ for (const svc of [...entries].sort((a, b) =>
1073
+ a.title.localeCompare(b.title),
1074
+ )) {
1075
+ if (rendered >= CATALOG_MAX_ENTRIES) {
1076
+ break;
1077
+ }
1078
+ const count = svc.columns.length;
1079
+ lines.push(`- ${svc.title} (${count} column${count === 1 ? '' : 's'})`);
1080
+ rendered += 1;
1081
+ }
1082
+ }
1083
+ return { lines, rendered };
1084
+ }
1085
+
1086
+ // Deterministic, grouped listing of a product's access points/services, so an
1087
+ // overview answer on a large product is never cut off by the LLM's token budget.
1088
+ export function buildAccessPointCatalog(services: TDSServiceSchema[]): string {
1089
+ const noun = services.some(
1090
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
1091
+ )
1092
+ ? 'access points'
1093
+ : 'services';
1094
+ const { lines, rendered } = renderCatalogEntries(
1095
+ groupServicesByAccessPointGroup(services),
1096
+ );
1097
+ const omitted = services.length - rendered;
1098
+ if (omitted > 0) {
1099
+ lines.push(
1100
+ `\n- ...and ${omitted} more (ask about a specific group to see them).`,
1101
+ );
1102
+ }
1103
+ const heading =
1104
+ omitted > 0
1105
+ ? `${rendered} of ${services.length}`
1106
+ : `All ${services.length}`;
1107
+ return [`## ${heading} ${noun}`, ...lines].join('\n');
1108
+ }
1109
+
1110
+ const MAX_METADATA_PROMPT_SERVICES = 30;
1111
+
1112
+ /**
1113
+ * Ranks the access points against the question so a product with hundreds of
1114
+ * them contributes a bounded, relevant slice of the metadata prompt.
1115
+ */
1116
+ function selectMetadataPromptServices(
1117
+ question: string,
1118
+ services: TDSServiceSchema[] | undefined,
1119
+ ): TDSServiceSchema[] | undefined {
1120
+ if (!services || services.length <= MAX_METADATA_PROMPT_SERVICES) {
1121
+ return services;
1122
+ }
1123
+ return preFilterServicesByRelevance(
1124
+ question,
1125
+ services,
1126
+ MAX_METADATA_PROMPT_SERVICES,
1127
+ );
1128
+ }
1129
+
971
1130
  export async function handleMetadataQuestion(
972
1131
  question: string,
973
1132
  metadata: LegendAIProductMetadata,
@@ -983,7 +1142,7 @@ export async function handleMetadataQuestion(
983
1142
  question,
984
1143
  metadata,
985
1144
  history,
986
- services,
1145
+ selectMetadataPromptServices(question, services),
987
1146
  modelContextEnrichment,
988
1147
  );
989
1148
  const rawAnswer = await plugin.callLLM(metadataPromptText, config);
@@ -993,9 +1152,15 @@ export async function handleMetadataQuestion(
993
1152
  hasQueryableServices === false && !config.orchestratorUrl
994
1153
  ? []
995
1154
  : parsedSuggestions;
1155
+ const textAnswer =
1156
+ services !== undefined &&
1157
+ services.length >= CATALOG_MIN_SERVICES &&
1158
+ isProductOverviewQuestion(question)
1159
+ ? `${answer}\n\n${buildAccessPointCatalog(services)}`
1160
+ : answer;
996
1161
  completeThinkingSteps(setMessages);
997
1162
  updateLastAssistant(setMessages, () => ({
998
- textAnswer: answer,
1163
+ textAnswer,
999
1164
  suggestedQueries,
1000
1165
  isProcessing: false,
1001
1166
  thinkingDuration: elapsedSeconds(startTime),
@@ -2025,6 +2190,7 @@ export async function processQuestionViaOrchestrator(
2025
2190
  pureExecutionContext?: QueryExplicitExecutionContextInfo,
2026
2191
  preResolvedEntities?: LegendAIResolvedEntities,
2027
2192
  modelContext?: LegendAIModelContext,
2193
+ priorFailure?: LegendAIPriorSqlFailure,
2028
2194
  ): Promise<void> {
2029
2195
  const { config, plugin, setMessages } = context;
2030
2196
  const startTime = Date.now();
@@ -2047,7 +2213,7 @@ export async function processQuestionViaOrchestrator(
2047
2213
  }
2048
2214
 
2049
2215
  addThinkingStep(setMessages, 'Generating Legend query via orchestrator...');
2050
- const enrichedContext = modelContext
2216
+ const baseContext = modelContext
2051
2217
  ? buildEnrichedBusinessContext(
2052
2218
  question,
2053
2219
  resolvedEntities.rootEntity,
@@ -2055,6 +2221,27 @@ export async function processQuestionViaOrchestrator(
2055
2221
  modelContext,
2056
2222
  )
2057
2223
  : undefined;
2224
+ const priorFailureHints = priorFailure
2225
+ ? buildPriorSqlFailureHints(
2226
+ priorFailure.failedSql,
2227
+ priorFailure.failedReason,
2228
+ )
2229
+ : [];
2230
+ const enrichedContext =
2231
+ priorFailureHints.length > 0
2232
+ ? {
2233
+ naturalLanguageQuery: question,
2234
+ ...baseContext,
2235
+ businessContextMatch: {
2236
+ ...baseContext?.businessContextMatch,
2237
+ additionalNlModelContext: [
2238
+ ...(baseContext?.businessContextMatch
2239
+ ?.additionalNlModelContext ?? []),
2240
+ ...priorFailureHints,
2241
+ ],
2242
+ },
2243
+ }
2244
+ : baseContext;
2058
2245
  const orchestratorResponse = await withTimeout(
2059
2246
  plugin.generateQueryViaOrchestrator(
2060
2247
  {
@@ -2463,14 +2650,16 @@ export function ensureSafeLimit(
2463
2650
  return `${sql.trimEnd()}\nLIMIT ${limit}`;
2464
2651
  }
2465
2652
 
2653
+ /**
2654
+ * The one place generated SQL text is rewritten, working around engine
2655
+ * transpiler limits on joins. Every step no-ops on a shape it cannot fix.
2656
+ */
2466
2657
  function prepareSafeSql(sql: string, services: TDSServiceSchema[]): string {
2467
- const joined = sanitizeLiteralColumns(
2468
- sanitizeJoinSameKeyColumns(
2469
- sanitizeJoinOrderBy(sanitizeJoinDuplicateColumns(sql, services)),
2470
- services,
2471
- ),
2472
- );
2473
- const wrapped = wrapBareJoinAccessPoints(joined, services);
2658
+ const deduped = sanitizeJoinDuplicateColumns(sql, services);
2659
+ const ordered = sanitizeJoinOrderBy(deduped);
2660
+ const keyed = sanitizeJoinSameKeyColumns(ordered, services);
2661
+ const literals = sanitizeLiteralColumns(keyed);
2662
+ const wrapped = wrapBareJoinAccessPoints(literals, services);
2474
2663
  const bounded = boundCrossAccessPointJoinDrivingSide(wrapped);
2475
2664
  const safeSql = ensureSafeLimit(ensureDateParameters(bounded, services));
2476
2665
  const unsupported = detectUnsupportedEnginePattern(safeSql);
@@ -2739,10 +2928,9 @@ function handleSqlGenerationFailure(
2739
2928
  updateLastAssistant(setMessages, () => ({
2740
2929
  textAnswer: orchestratorMessage,
2741
2930
  suggestedQueries: suggestions,
2742
- fallbackAction: {
2743
- label: ORCHESTRATOR_FALLBACK_LABEL,
2744
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2745
- },
2931
+ fallbackAction: buildOrchestratorFallbackAction({
2932
+ failedReason: orchestratorMessage,
2933
+ }),
2746
2934
  isProcessing: false,
2747
2935
  thinkingDuration: elapsedSeconds(startTime),
2748
2936
  }));
@@ -2762,6 +2950,7 @@ interface QueryResultReport {
2762
2950
  question: string;
2763
2951
  services: TDSServiceSchema[];
2764
2952
  allAccessPoints?: TDSServiceSchema[];
2953
+ zeroRowExplanation?: string;
2765
2954
  }
2766
2955
 
2767
2956
  async function reportQueryResults(
@@ -2771,7 +2960,14 @@ async function reportQueryResults(
2771
2960
  startTime: number,
2772
2961
  hasOrchestratorFallback: boolean,
2773
2962
  ): Promise<void> {
2774
- const { currentSql, sqlResult, question, services, allAccessPoints } = report;
2963
+ const {
2964
+ currentSql,
2965
+ sqlResult,
2966
+ question,
2967
+ services,
2968
+ allAccessPoints,
2969
+ zeroRowExplanation,
2970
+ } = report;
2775
2971
  const { config, plugin, setMessages } = context;
2776
2972
  if (sqlResult.rows.length > 0) {
2777
2973
  const columns = deduplicateColumns(sqlResult.columns);
@@ -2819,12 +3015,13 @@ async function reportQueryResults(
2819
3015
  } else {
2820
3016
  addThinkingStep(
2821
3017
  setMessages,
2822
- 'Query returned 0 rows after correction attempts.',
2823
- );
2824
- const joinExplanation = buildCrossJoinZeroRowExplanation(
2825
- currentSql,
2826
- services,
3018
+ zeroRowExplanation === undefined
3019
+ ? 'Query returned 0 rows after correction attempts.'
3020
+ : 'No overlapping join keys — the query would return 0 rows.',
2827
3021
  );
3022
+ const joinExplanation =
3023
+ zeroRowExplanation ??
3024
+ buildCrossJoinZeroRowExplanation(currentSql, services);
2828
3025
  const joinSuggestions =
2829
3026
  joinExplanation === undefined
2830
3027
  ? []
@@ -2837,6 +3034,7 @@ async function reportQueryResults(
2837
3034
  metadata,
2838
3035
  config,
2839
3036
  joinExplanation,
3037
+ services,
2840
3038
  )
2841
3039
  .catch((error: unknown) => {
2842
3040
  assertErrorThrown(error);
@@ -2849,19 +3047,18 @@ async function reportQueryResults(
2849
3047
  joinSuggestions.length > 0
2850
3048
  ? joinSuggestions
2851
3049
  : (llmAnalysis?.suggestedQueries ?? []);
3050
+ const zeroRowReason =
3051
+ llmAnalysis?.summary ?? joinExplanation ?? buildZeroRowMessage(services);
2852
3052
  const fallback = hasOrchestratorFallback
2853
3053
  ? {
2854
- fallbackAction: {
2855
- label: ORCHESTRATOR_FALLBACK_LABEL,
2856
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2857
- },
3054
+ fallbackAction: buildOrchestratorFallbackAction({
3055
+ failedSql: currentSql,
3056
+ failedReason: zeroRowReason,
3057
+ }),
2858
3058
  }
2859
3059
  : {};
2860
3060
  updateLastAssistant(setMessages, () => ({
2861
- textAnswer:
2862
- llmAnalysis?.summary ??
2863
- joinExplanation ??
2864
- buildZeroRowMessage(services),
3061
+ textAnswer: zeroRowReason,
2865
3062
  ...(suggestions.length > 0 ? { suggestedQueries: suggestions } : {}),
2866
3063
  ...fallback,
2867
3064
  isProcessing: false,
@@ -3005,12 +3202,12 @@ export function applyMultiTurnBias(
3005
3202
  // Sort so that previously-used APs come first, preserving relative order
3006
3203
  const biased = [...services].sort((a, b) => {
3007
3204
  const aUsed =
3008
- previousAPs.has(a.pattern.replace(/^\//, '').toLowerCase()) ||
3205
+ previousAPs.has(accessPointName(a).toLowerCase()) ||
3009
3206
  previousAPs.has(a.title.toLowerCase())
3010
3207
  ? 1
3011
3208
  : 0;
3012
3209
  const bUsed =
3013
- previousAPs.has(b.pattern.replace(/^\//, '').toLowerCase()) ||
3210
+ previousAPs.has(accessPointName(b).toLowerCase()) ||
3014
3211
  previousAPs.has(b.title.toLowerCase())
3015
3212
  ? 1
3016
3213
  : 0;
@@ -3185,6 +3382,12 @@ export function resolveFilterLiteral(
3185
3382
 
3186
3383
  type FilterValueProbe = (column: string) => Promise<string[] | undefined>;
3187
3384
 
3385
+ interface ColumnValueProbeOptions {
3386
+ rowLimit?: number;
3387
+ maxColumns?: number;
3388
+ silent?: boolean;
3389
+ }
3390
+
3188
3391
  // Rewrites `"col" = 'literal'` predicates in the WHERE clause whose literal
3189
3392
  // fuzzy-matches a real probed column value, returning the updated SQL and
3190
3393
  // whether anything changed. Every distinct predicate is corrected (a column can
@@ -3373,34 +3576,24 @@ function buildAccessPointColumnIndex(
3373
3576
  return index;
3374
3577
  }
3375
3578
 
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[],
3579
+ // Builds a memoized distinct-value prober for a query's columns: access-point
3580
+ // columns resolve via a relation query, service columns via SELECT DISTINCT.
3581
+ export function createColumnValueProbe(
3582
+ services: TDSServiceSchema[],
3381
3583
  dataProductCoordinates:
3382
3584
  | LegendAIOrchestratorDataProductCoordinates
3383
3585
  | undefined,
3586
+ fromClause: string | undefined,
3587
+ isAccessPointProbe: boolean,
3384
3588
  context: LegendAIOperationContext,
3385
- ): Promise<
3386
- { sql: string; result: LegendAISqlExecutionResultData } | undefined
3387
- > {
3589
+ options?: ColumnValueProbeOptions,
3590
+ ): FilterValueProbe {
3388
3591
  const { plugin, config, setMessages } = context;
3389
- const isAccessPointProbe =
3390
- dataProductCoordinates !== undefined &&
3391
- selectedServices.some(
3392
- (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
3393
- );
3592
+ const rowLimit = options?.rowLimit ?? DISTINCT_PROBE_ROW_LIMIT;
3593
+ const maxColumns = options?.maxColumns ?? MAX_RECOVERED_FILTER_COLUMNS;
3394
3594
  const accessPointByColumn = isAccessPointProbe
3395
- ? buildAccessPointColumnIndex(selectedServices)
3595
+ ? buildAccessPointColumnIndex(services)
3396
3596
  : new Map<string, TDSServiceSchema>();
3397
- const fromClause = isAccessPointProbe
3398
- ? undefined
3399
- : extractFromClause(currentSql);
3400
- if (!isAccessPointProbe && fromClause === undefined) {
3401
- return undefined;
3402
- }
3403
-
3404
3597
  const probeAccessPointColumn = async (
3405
3598
  column: string,
3406
3599
  ): Promise<LegendAISqlExecutionResultData> => {
@@ -3409,22 +3602,19 @@ async function attemptDistinctValueRecovery(
3409
3602
  if (pId === undefined) {
3410
3603
  return { columns: [], rows: [] };
3411
3604
  }
3412
- const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`;
3605
+ const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${rowLimit})`;
3413
3606
  return plugin.executeLakehouseRelationQuery(
3414
3607
  relationQuery,
3415
3608
  guaranteeNonNullable(dataProductCoordinates),
3416
3609
  config,
3417
3610
  );
3418
3611
  };
3419
-
3420
3612
  const probedValues = new Map<string, string[] | undefined>();
3421
- const probeColumnValues = async (
3422
- column: string,
3423
- ): Promise<string[] | undefined> => {
3613
+ return async (column: string): Promise<string[] | undefined> => {
3424
3614
  if (probedValues.has(column)) {
3425
3615
  return probedValues.get(column);
3426
3616
  }
3427
- if (probedValues.size >= MAX_RECOVERED_FILTER_COLUMNS) {
3617
+ if (probedValues.size >= maxColumns) {
3428
3618
  return undefined;
3429
3619
  }
3430
3620
  let values: string[] | undefined;
@@ -3432,8 +3622,8 @@ async function attemptDistinctValueRecovery(
3432
3622
  const probe = isAccessPointProbe
3433
3623
  ? await probeAccessPointColumn(column)
3434
3624
  : await executeSqlForServices(
3435
- `SELECT DISTINCT "${column}" ${fromClause} LIMIT ${DISTINCT_PROBE_ROW_LIMIT}`,
3436
- selectedServices,
3625
+ `SELECT DISTINCT "${column}" ${fromClause} LIMIT ${rowLimit}`,
3626
+ services,
3437
3627
  dataProductCoordinates,
3438
3628
  plugin,
3439
3629
  config,
@@ -3444,12 +3634,145 @@ async function attemptDistinctValueRecovery(
3444
3634
  .map(String);
3445
3635
  } catch (probeError) {
3446
3636
  assertErrorThrown(probeError);
3447
- addThinkingStep(setMessages, `Could not probe values for "${column}"`);
3637
+ if (options?.silent !== true) {
3638
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
3639
+ }
3448
3640
  values = undefined;
3449
3641
  }
3450
3642
  probedValues.set(column, values);
3451
3643
  return values;
3452
3644
  };
3645
+ }
3646
+
3647
+ // An empty inner join yields no rows, but an outer join, an aggregate, a
3648
+ // disjunctive predicate or a set operation can still return some.
3649
+ export function canSkipExecutionOnEmptyJoin(sql: string): boolean {
3650
+ return (
3651
+ !OUTER_JOIN_PATTERN.test(sql) &&
3652
+ !HAS_AGGREGATION_PATTERN.test(sql) &&
3653
+ !DISJUNCTIVE_PREDICATE_PATTERN.test(sql) &&
3654
+ !SET_OPERATION_PATTERN.test(sql)
3655
+ );
3656
+ }
3657
+
3658
+ /**
3659
+ * Reports the join key whose values do not overlap, so a two access point join
3660
+ * can be explained instead of run. Text keys only, because dates and numbers
3661
+ * serialize differently on either side and would read as disjoint when they
3662
+ * are not, and only for exactly two access points, because a key belonging to
3663
+ * another pair would otherwise be tested against the wrong relations.
3664
+ */
3665
+ export async function probeJoinOverlap(
3666
+ sql: string,
3667
+ services: TDSServiceSchema[],
3668
+ joinKeys: Set<string>,
3669
+ dataProductCoordinates:
3670
+ | LegendAIOrchestratorDataProductCoordinates
3671
+ | undefined,
3672
+ context: LegendAIOperationContext,
3673
+ ): Promise<string | undefined> {
3674
+ if (dataProductCoordinates === undefined) {
3675
+ return undefined;
3676
+ }
3677
+ const involved = resolveJoinedAccessPoints(sql, services);
3678
+ const apA = involved[0];
3679
+ const apB = involved[1];
3680
+ if (involved.length !== 2 || apA === undefined || apB === undefined) {
3681
+ return undefined;
3682
+ }
3683
+ const sharedKeys = sharedColumnNames(apA, apB)
3684
+ .filter((name) => joinKeys.has(name.toLowerCase()))
3685
+ .slice(0, MAX_JOIN_OVERLAP_PROBE_KEYS);
3686
+ if (sharedKeys.length === 0) {
3687
+ return undefined;
3688
+ }
3689
+ const columnsA = buildColumnByNameIndex(apA.columns);
3690
+ const columnsB = buildColumnByNameIndex(apB.columns);
3691
+ const probeOptions: ColumnValueProbeOptions = {
3692
+ rowLimit: JOIN_OVERLAP_PROBE_LIMIT,
3693
+ maxColumns: MAX_JOIN_OVERLAP_PROBE_KEYS,
3694
+ silent: true,
3695
+ };
3696
+ const probeA = createColumnValueProbe(
3697
+ [apA],
3698
+ dataProductCoordinates,
3699
+ undefined,
3700
+ true,
3701
+ context,
3702
+ probeOptions,
3703
+ );
3704
+ const probeB = createColumnValueProbe(
3705
+ [apB],
3706
+ dataProductCoordinates,
3707
+ undefined,
3708
+ true,
3709
+ context,
3710
+ probeOptions,
3711
+ );
3712
+ const fullyEnumerated = (values: string[] | undefined): values is string[] =>
3713
+ values !== undefined &&
3714
+ values.length > 0 &&
3715
+ values.length < JOIN_OVERLAP_CONCLUSIVE_MAX;
3716
+ for (const key of sharedKeys) {
3717
+ const columnA = columnsA.get(key.toLowerCase());
3718
+ const columnB = columnsB.get(key.toLowerCase());
3719
+ if (columnA === undefined || columnB === undefined) {
3720
+ continue;
3721
+ }
3722
+ const nameB = columnB.name;
3723
+ if (!isStringTypedColumn(columnA) || !isStringTypedColumn(columnB)) {
3724
+ continue;
3725
+ }
3726
+ const [valuesA, valuesB] = await Promise.all([probeA(key), probeB(nameB)]);
3727
+ if (!fullyEnumerated(valuesA) || !fullyEnumerated(valuesB)) {
3728
+ continue;
3729
+ }
3730
+ const setB = new Set(valuesB.map((v) => v.toLowerCase()));
3731
+ if (!valuesA.some((v) => setB.has(v.toLowerCase()))) {
3732
+ return buildDisjointJoinMessage(
3733
+ apA.title,
3734
+ apB.title,
3735
+ key,
3736
+ previewValues(valuesA),
3737
+ previewValues(valuesB),
3738
+ );
3739
+ }
3740
+ }
3741
+ return undefined;
3742
+ }
3743
+
3744
+ // Recovers a zero-row query by re-probing each WHERE-filter column's real distinct
3745
+ // values and rewriting equality/IN literals that don't match (casing/spelling).
3746
+ async function attemptDistinctValueRecovery(
3747
+ currentSql: string,
3748
+ selectedServices: TDSServiceSchema[],
3749
+ dataProductCoordinates:
3750
+ | LegendAIOrchestratorDataProductCoordinates
3751
+ | undefined,
3752
+ context: LegendAIOperationContext,
3753
+ ): Promise<
3754
+ { sql: string; result: LegendAISqlExecutionResultData } | undefined
3755
+ > {
3756
+ const { plugin, config, setMessages } = context;
3757
+ const isAccessPointProbe =
3758
+ dataProductCoordinates !== undefined &&
3759
+ selectedServices.some(
3760
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
3761
+ );
3762
+ const fromClause = isAccessPointProbe
3763
+ ? undefined
3764
+ : extractFromClause(currentSql);
3765
+ if (!isAccessPointProbe && fromClause === undefined) {
3766
+ return undefined;
3767
+ }
3768
+
3769
+ const probeColumnValues = createColumnValueProbe(
3770
+ selectedServices,
3771
+ dataProductCoordinates,
3772
+ fromClause,
3773
+ isAccessPointProbe,
3774
+ context,
3775
+ );
3453
3776
 
3454
3777
  await Promise.all(
3455
3778
  collectWhereFilterColumns(currentSql)
@@ -3794,7 +4117,7 @@ async function processAccessPointQuery(
3794
4117
  const selectedAPs = await selectBestServices(question, accessPoints, context);
3795
4118
 
3796
4119
  const currentAccessPoints = new Set(
3797
- selectedAPs.map((ap) => ap.pattern.replace(/^\//, '')),
4120
+ selectedAPs.map((ap) => accessPointName(ap)),
3798
4121
  );
3799
4122
  const scopedContext: LegendAIOperationContext = {
3800
4123
  ...context,
@@ -3808,6 +4131,7 @@ async function processAccessPointQuery(
3808
4131
  selectedAPs,
3809
4132
  dataProductCoordinates,
3810
4133
  config,
4134
+ question,
3811
4135
  ),
3812
4136
  VALUE_GROUNDING_TIME_BUDGET_MS,
3813
4137
  );
@@ -3855,10 +4179,56 @@ async function processAccessPointQuery(
3855
4179
  updateLastAssistant(setMessages, () => ({
3856
4180
  sql: finalSql,
3857
4181
  sqlGenTime: sqlGenTimeValue,
3858
- isExecuting: true,
3859
4182
  queriedAccessPoints: orderedAccessPointsFromSql(finalSql),
3860
4183
  }));
3861
4184
 
4185
+ const joinKeys = canSkipExecutionOnEmptyJoin(finalSql)
4186
+ ? extractJoinKeyColumns(finalSql)
4187
+ : new Set<string>();
4188
+ let disjointJoin =
4189
+ joinKeys.size > 0
4190
+ ? detectDisjointJoinUniverses(finalSql, selectedAPs, {
4191
+ requireComplete: true,
4192
+ restrictToColumns: joinKeys,
4193
+ })
4194
+ : undefined;
4195
+ if (disjointJoin === undefined && joinKeys.size > 0) {
4196
+ addThinkingStep(setMessages, 'Probing join-key overlap...');
4197
+ disjointJoin = await withTimeout(
4198
+ probeJoinOverlap(
4199
+ finalSql,
4200
+ selectedAPs,
4201
+ joinKeys,
4202
+ dataProductCoordinates,
4203
+ scopedContext,
4204
+ ),
4205
+ JOIN_OVERLAP_PROBE_TIMEOUT_MS,
4206
+ );
4207
+ }
4208
+ if (disjointJoin !== undefined) {
4209
+ addThinkingStep(
4210
+ setMessages,
4211
+ 'Join keys do not overlap — skipping execution',
4212
+ );
4213
+ await reportQueryResults(
4214
+ {
4215
+ currentSql: finalSql,
4216
+ sqlResult: { columns: [], rows: [] },
4217
+ question,
4218
+ services: selectedAPs,
4219
+ allAccessPoints: accessPoints,
4220
+ zeroRowExplanation: disjointJoin,
4221
+ },
4222
+ metadata,
4223
+ scopedContext,
4224
+ startTime,
4225
+ false,
4226
+ );
4227
+ return;
4228
+ }
4229
+
4230
+ updateLastAssistant(setMessages, () => ({ isExecuting: true }));
4231
+
3862
4232
  const execStartTime = Date.now();
3863
4233
  try {
3864
4234
  let execSql = finalSql;
@@ -3990,16 +4360,22 @@ async function processDataQuery(
3990
4360
  const missingParams = detectMissingServiceParams(judgedSql, selectedServices);
3991
4361
  if (missingParams.length > 0) {
3992
4362
  const sqlGenTimeValue = elapsedSeconds(startTime, 2);
4363
+ const missingParamsReason = `Missing required parameter${missingParams.length > 1 ? 's' : ''}: ${missingParams.map((p) => p.name).join(', ')}`;
3993
4364
  completeThinkingSteps(setMessages);
3994
- addThinkingStep(
3995
- setMessages,
3996
- `Missing required parameter${missingParams.length > 1 ? 's' : ''}: ${missingParams.map((p) => p.name).join(', ')}`,
3997
- );
4365
+ addThinkingStep(setMessages, missingParamsReason);
3998
4366
  completeThinkingSteps(setMessages);
3999
4367
  updateLastAssistant(setMessages, () => ({
4000
4368
  sql: judgedSql,
4001
4369
  sqlGenTime: sqlGenTimeValue,
4002
4370
  textAnswer: buildMissingParamsWarning(missingParams),
4371
+ ...(hasOrchestratorFallback
4372
+ ? {
4373
+ fallbackAction: buildOrchestratorFallbackAction({
4374
+ failedSql: judgedSql,
4375
+ failedReason: missingParamsReason,
4376
+ }),
4377
+ }
4378
+ : {}),
4003
4379
  isProcessing: false,
4004
4380
  isExecuting: false,
4005
4381
  thinkingDuration: elapsedSeconds(startTime),
@@ -4025,14 +4401,15 @@ async function processDataQuery(
4025
4401
  );
4026
4402
 
4027
4403
  if (execOutcome.error) {
4028
- const execErrorType = classifyError(new Error(execOutcome.error));
4404
+ const execError = execOutcome.error;
4405
+ const execErrorType = classifyError(new Error(execError));
4029
4406
  addThinkingStep(
4030
4407
  setMessages,
4031
- `Execution failed: ${execOutcome.error.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
4408
+ `Execution failed: ${execError.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`,
4032
4409
  );
4033
4410
  finishWithThinkingError(
4034
4411
  setMessages,
4035
- buildExecutionErrorMessage(execOutcome.error, selectedServices),
4412
+ buildExecutionErrorMessage(execError, selectedServices),
4036
4413
  startTime,
4037
4414
  execErrorType === LegendAIErrorType.GENERAL
4038
4415
  ? LegendAIErrorType.EXECUTION
@@ -4043,10 +4420,10 @@ async function processDataQuery(
4043
4420
  suggestedQueries: buildFallbackSuggestions(selectedServices),
4044
4421
  ...(hasOrchestratorFallback
4045
4422
  ? {
4046
- fallbackAction: {
4047
- label: ORCHESTRATOR_FALLBACK_LABEL,
4048
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
4049
- },
4423
+ fallbackAction: buildOrchestratorFallbackAction({
4424
+ failedSql: execOutcome.sql,
4425
+ failedReason: execError,
4426
+ }),
4050
4427
  }
4051
4428
  : {}),
4052
4429
  }));
@@ -4217,10 +4594,7 @@ export async function processQuestion(
4217
4594
  );
4218
4595
  if (config.orchestratorUrl && dataProductCoordinates) {
4219
4596
  updateLastAssistant(setMessages, () => ({
4220
- fallbackAction: {
4221
- label: ORCHESTRATOR_FALLBACK_LABEL,
4222
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
4223
- },
4597
+ fallbackAction: buildOrchestratorFallbackAction(),
4224
4598
  }));
4225
4599
  }
4226
4600
  return;
@@ -4415,10 +4789,7 @@ export async function processQuestionWithIntent(
4415
4789
  getMetadataEnrichment(),
4416
4790
  );
4417
4791
  updateLastAssistant(setMessages, () => ({
4418
- fallbackAction: {
4419
- label: ORCHESTRATOR_FALLBACK_LABEL,
4420
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
4421
- },
4792
+ fallbackAction: buildOrchestratorFallbackAction(),
4422
4793
  }));
4423
4794
  return;
4424
4795
  }