@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
@@ -17,11 +17,11 @@ import { assertErrorThrown, guaranteeNonNullable, isNonNullable, uuid, } from '@
17
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, buildModelCatalogText, buildDataQueryApproachText, findBestAlternateRoot, rankEntities, relaxExactStringFilters, extractFilteredColumns, buildProbedValueHints, splitIdentifierTokens, tokenizeText, } from '../LegendAIDocEnrichment.js';
20
+ import { buildEnrichedBusinessContext, buildModelContextEnrichmentText, buildModelCatalogText, buildDataQueryApproachText, findBestAlternateRoot, rankEntities, relaxExactStringFilters, extractFilteredColumns, buildProbedValueHints, buildPriorSqlFailureHints, splitIdentifierTokens, tokenizeText, } from '../LegendAIDocEnrichment.js';
21
21
  import { isFuzzyMatch, preFilterServicesByRelevance, } from '../LegendAIServiceRetrieval.js';
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';
22
+ import { isNumericColumn, isStringColumn, isStringTypedColumn, } from '../components/LegendAIChatHelpers.js';
23
+ import { buildColumnByNameIndex, accessPointName, HAS_LIMIT_PATTERN, accessPointCalls, servicePId, pureRelationColumnRef, extractJoinKeyColumns, sharedColumnNames, } from './LegendAISqlHelpers.js';
24
+ import { buildCrossJoinZeroRowExplanation, buildJoinablePairSuggestions, detectDisjointJoinUniverses, resolveJoinedAccessPoints, buildDisjointJoinMessage, previewValues, } from './LegendAIJoinAnalysis.js';
25
25
  import { boundCrossAccessPointJoinDrivingSide, sanitizeJoinDuplicateColumns, sanitizeJoinOrderBy, sanitizeJoinSameKeyColumns, wrapBareJoinAccessPoints, } from './LegendAISqlJoinSanitizers.js';
26
26
  const MAX_ERROR_MESSAGE_LENGTH = 500;
27
27
  const MAX_DISPLAYED_ERROR_LENGTH = 4000;
@@ -32,6 +32,10 @@ const ANALYSIS_TIMEOUT_MS = 15_000;
32
32
  const ORCHESTRATOR_GENERATION_TIMEOUT_MS = 120_000;
33
33
  const DISTINCT_PROBE_ROW_LIMIT = 5000;
34
34
  const MAX_RECOVERED_FILTER_COLUMNS = 4;
35
+ export const JOIN_OVERLAP_PROBE_LIMIT = 200;
36
+ const JOIN_OVERLAP_CONCLUSIVE_MAX = JOIN_OVERLAP_PROBE_LIMIT - 1;
37
+ const MAX_JOIN_OVERLAP_PROBE_KEYS = 4;
38
+ const JOIN_OVERLAP_PROBE_TIMEOUT_MS = 15_000;
35
39
  const EXECUTION_TIMEOUT_MS = 300_000;
36
40
  const ANALYSIS_PREVIEW_ROW_LIMIT = 3;
37
41
  const ANALYSIS_PREVIEW_VALUE_LIMIT = 40;
@@ -42,11 +46,28 @@ const LITERAL_COL_PATTERN = /,\s*'[^']*'\s+AS\s+(?:"[^"]+"|[a-z]\w*)/gi;
42
46
  const SERVICE_CALL_PATTERN = /\bservice\s*\([^()]*\)/gi;
43
47
  const DEFAULT_SAFETY_LIMIT = 1000;
44
48
  const HAS_AGGREGATION_PATTERN = /\bGROUP\s+BY\b|\b(?:COUNT|SUM|AVG|MIN|MAX)\s*\(|\bSELECT\s+DISTINCT\b|\bHAVING\b|\bQUALIFY\b/i;
49
+ const OUTER_JOIN_PATTERN = /\b(?:LEFT|RIGHT|FULL)\s+(?:OUTER\s+)?JOIN\b/i;
50
+ const DISJUNCTIVE_PREDICATE_PATTERN = /\bOR\b/i;
51
+ const SET_OPERATION_PATTERN = /\b(?:UNION|INTERSECT|EXCEPT)\b/i;
45
52
  const MAX_SERVICES_FOR_LLM_SELECTION = 30;
46
53
  const VALUE_GROUNDING_TIME_BUDGET_MS = 4000;
47
54
  const AP_SQL_EXECUTION_TIMEOUT_MS = 90_000;
48
55
  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.';
49
56
  const ORCHESTRATOR_FALLBACK_LABEL = 'Try Legend AI Orchestrator';
57
+ // The single orchestrator fallback action, so every dead-end path offers the
58
+ // same button and threads any prior SQL failure the same way.
59
+ export function buildOrchestratorFallbackAction(failure) {
60
+ return {
61
+ label: ORCHESTRATOR_FALLBACK_LABEL,
62
+ actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
63
+ ...(failure?.failedSql === undefined
64
+ ? {}
65
+ : { failedSql: failure.failedSql }),
66
+ ...(failure?.failedReason === undefined
67
+ ? {}
68
+ : { failedReason: failure.failedReason }),
69
+ };
70
+ }
50
71
  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.';
51
72
  const SQL_GENERATION_FAILURE_NO_ORCHESTRATOR = 'SQL generation could not handle this query. Try rephrasing your question.';
52
73
  const SERVICE_PARAM_DATE_LIKE_PATTERNS = [
@@ -91,29 +112,32 @@ function deduplicateColumns(columns) {
91
112
  return count === 0 ? col : `${col}_${count + 1}`;
92
113
  });
93
114
  }
115
+ export function createAssistantMessage() {
116
+ return {
117
+ id: uuid(),
118
+ role: LegendAIMessageRole.ASSISTANT,
119
+ thinkingSteps: [],
120
+ sql: null,
121
+ textAnswer: null,
122
+ dataContext: null,
123
+ gridData: null,
124
+ error: null,
125
+ errorType: null,
126
+ sqlGenTime: null,
127
+ execTime: null,
128
+ thinkingDuration: null,
129
+ isProcessing: true,
130
+ isExecuting: false,
131
+ suggestedQueries: [],
132
+ fallbackAction: null,
133
+ queriedAccessPointGroups: [],
134
+ queriedAccessPoints: [],
135
+ };
136
+ }
94
137
  export function createMessagePair(text) {
95
138
  return [
96
139
  { id: uuid(), role: LegendAIMessageRole.USER, text },
97
- {
98
- id: uuid(),
99
- role: LegendAIMessageRole.ASSISTANT,
100
- thinkingSteps: [],
101
- sql: null,
102
- textAnswer: null,
103
- dataContext: null,
104
- gridData: null,
105
- error: null,
106
- errorType: null,
107
- sqlGenTime: null,
108
- execTime: null,
109
- thinkingDuration: null,
110
- isProcessing: true,
111
- isExecuting: false,
112
- suggestedQueries: [],
113
- fallbackAction: null,
114
- queriedAccessPointGroups: [],
115
- queriedAccessPoints: [],
116
- },
140
+ createAssistantMessage(),
117
141
  ];
118
142
  }
119
143
  export function updateLastAssistant(setMessages, updater) {
@@ -236,7 +260,7 @@ export function filterHistoryForAccessPoints(history, currentAccessPoints) {
236
260
  function orderedAccessPointsFromSql(sql) {
237
261
  const ordered = [];
238
262
  const seen = new Set();
239
- for (const match of sql.matchAll(AP_CALL_PATTERN)) {
263
+ for (const match of accessPointCalls(sql)) {
240
264
  const path = match.groups?.pId ?? '';
241
265
  const dotIdx = path.lastIndexOf('.');
242
266
  const apName = dotIdx === -1 ? path : path.slice(dotIdx + 1);
@@ -247,6 +271,8 @@ function orderedAccessPointsFromSql(sql) {
247
271
  }
248
272
  return ordered;
249
273
  }
274
+ const MAX_HISTORY_ANSWER_LENGTH = 1500;
275
+ const MAX_CONVERSATION_HISTORY_TURNS = 10;
250
276
  function buildTurnFromAssistant(userText, asstMsg) {
251
277
  if (asstMsg.sql) {
252
278
  const turn = {
@@ -274,7 +300,7 @@ function buildTurnFromAssistant(userText, asstMsg) {
274
300
  if (asstMsg.textAnswer) {
275
301
  return {
276
302
  question: userText,
277
- sql: asstMsg.textAnswer,
303
+ sql: asstMsg.textAnswer.slice(0, MAX_HISTORY_ANSWER_LENGTH),
278
304
  intent: LegendAIQuestionIntent.METADATA,
279
305
  };
280
306
  }
@@ -309,6 +335,10 @@ export function classifyResponseOutcome(message) {
309
335
  }
310
336
  return LegendAIResponseOutcome.NO_ANSWER;
311
337
  }
338
+ /**
339
+ * Pairs each question with the assistant turn that answered it, keeping only
340
+ * the most recent turns so prompts do not grow with conversation length.
341
+ */
312
342
  export function buildConversationHistory(messages) {
313
343
  const history = [];
314
344
  let i = 0;
@@ -327,7 +357,7 @@ export function buildConversationHistory(messages) {
327
357
  i += 1;
328
358
  }
329
359
  }
330
- return history;
360
+ return history.slice(-MAX_CONVERSATION_HISTORY_TURNS);
331
361
  }
332
362
  function formatServiceParams(services) {
333
363
  return services.flatMap((s) => {
@@ -690,18 +720,103 @@ export function attachMetadataOverview(setMessages, metadataOverview) {
690
720
  };
691
721
  });
692
722
  }
723
+ const PRODUCT_OVERVIEW_PATTERNS = [
724
+ /\bwhat\s+does\b.*\b(?:offer|provide|contain|have|include|cover)\b/,
725
+ /\b(?:describe|summarize|overview|summary)\b/,
726
+ /\bwhat\s+(?:access\s*points?|services?|data|datasets?)\b/,
727
+ /\bwhat\s+can\s+(?:i|we|you)\b/,
728
+ /\btell\s+me\s+(?:about|more)\b/,
729
+ ];
730
+ const LISTING_VERB_PATTERN = /\b(?:list|show|what\s+are)\b/;
731
+ const LISTING_NOUN_PATTERN = /\b(?:access\s*points?|services?|endpoints?|datasets?|tables?)\b/;
732
+ export function isProductOverviewQuestion(question) {
733
+ const q = question.toLowerCase();
734
+ return (PRODUCT_OVERVIEW_PATTERNS.some((pattern) => pattern.test(q)) ||
735
+ (LISTING_VERB_PATTERN.test(q) && LISTING_NOUN_PATTERN.test(q)));
736
+ }
737
+ const CATALOG_MIN_SERVICES = 10;
738
+ const CATALOG_MAX_ENTRIES = 200;
739
+ // Buckets services under their access point group, groups in title order.
740
+ function groupServicesByAccessPointGroup(services) {
741
+ const byGroup = new Map();
742
+ for (const svc of services) {
743
+ const group = svc.accessPointGroupTitle ?? '';
744
+ const existing = byGroup.get(group);
745
+ if (existing) {
746
+ existing.push(svc);
747
+ }
748
+ else {
749
+ byGroup.set(group, [svc]);
750
+ }
751
+ }
752
+ return Array.from(byGroup.entries()).sort((a, b) => a[0].localeCompare(b[0]));
753
+ }
754
+ // Renders each group as a heading followed by its services, up to the cap.
755
+ function renderCatalogEntries(groups) {
756
+ const lines = [];
757
+ let rendered = 0;
758
+ for (const [group, entries] of groups) {
759
+ if (rendered >= CATALOG_MAX_ENTRIES) {
760
+ break;
761
+ }
762
+ if (group) {
763
+ lines.push(`\n### ${group}`);
764
+ }
765
+ for (const svc of [...entries].sort((a, b) => a.title.localeCompare(b.title))) {
766
+ if (rendered >= CATALOG_MAX_ENTRIES) {
767
+ break;
768
+ }
769
+ const count = svc.columns.length;
770
+ lines.push(`- ${svc.title} (${count} column${count === 1 ? '' : 's'})`);
771
+ rendered += 1;
772
+ }
773
+ }
774
+ return { lines, rendered };
775
+ }
776
+ // Deterministic, grouped listing of a product's access points/services, so an
777
+ // overview answer on a large product is never cut off by the LLM's token budget.
778
+ export function buildAccessPointCatalog(services) {
779
+ const noun = services.some((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT)
780
+ ? 'access points'
781
+ : 'services';
782
+ const { lines, rendered } = renderCatalogEntries(groupServicesByAccessPointGroup(services));
783
+ const omitted = services.length - rendered;
784
+ if (omitted > 0) {
785
+ lines.push(`\n- ...and ${omitted} more (ask about a specific group to see them).`);
786
+ }
787
+ const heading = omitted > 0
788
+ ? `${rendered} of ${services.length}`
789
+ : `All ${services.length}`;
790
+ return [`## ${heading} ${noun}`, ...lines].join('\n');
791
+ }
792
+ const MAX_METADATA_PROMPT_SERVICES = 30;
793
+ /**
794
+ * Ranks the access points against the question so a product with hundreds of
795
+ * them contributes a bounded, relevant slice of the metadata prompt.
796
+ */
797
+ function selectMetadataPromptServices(question, services) {
798
+ if (!services || services.length <= MAX_METADATA_PROMPT_SERVICES) {
799
+ return services;
800
+ }
801
+ return preFilterServicesByRelevance(question, services, MAX_METADATA_PROMPT_SERVICES);
802
+ }
693
803
  export async function handleMetadataQuestion(question, metadata, context, startTime, hasQueryableServices, services, modelContextEnrichment) {
694
804
  const { config, plugin, history, setMessages } = context;
695
805
  addThinkingStep(setMessages, 'Answering from product metadata...');
696
- const metadataPromptText = plugin.buildMetadataPrompt(question, metadata, history, services, modelContextEnrichment);
806
+ const metadataPromptText = plugin.buildMetadataPrompt(question, metadata, history, selectMetadataPromptServices(question, services), modelContextEnrichment);
697
807
  const rawAnswer = await plugin.callLLM(metadataPromptText, config);
698
808
  const { answer, suggestedQueries: parsedSuggestions } = parseSuggestedQueries(rawAnswer);
699
809
  const suggestedQueries = hasQueryableServices === false && !config.orchestratorUrl
700
810
  ? []
701
811
  : parsedSuggestions;
812
+ const textAnswer = services !== undefined &&
813
+ services.length >= CATALOG_MIN_SERVICES &&
814
+ isProductOverviewQuestion(question)
815
+ ? `${answer}\n\n${buildAccessPointCatalog(services)}`
816
+ : answer;
702
817
  completeThinkingSteps(setMessages);
703
818
  updateLastAssistant(setMessages, () => ({
704
- textAnswer: answer,
819
+ textAnswer,
705
820
  suggestedQueries,
706
821
  isProcessing: false,
707
822
  thinkingDuration: elapsedSeconds(startTime),
@@ -1205,7 +1320,7 @@ async function reportOrchestratorExecutionResult(params, context) {
1205
1320
  }));
1206
1321
  }
1207
1322
  }
1208
- export async function processQuestionViaOrchestrator(question, dataProductCoordinates, metadata, context, pureExecutionContext, preResolvedEntities, modelContext) {
1323
+ export async function processQuestionViaOrchestrator(question, dataProductCoordinates, metadata, context, pureExecutionContext, preResolvedEntities, modelContext, priorFailure) {
1209
1324
  const { config, plugin, setMessages } = context;
1210
1325
  const startTime = Date.now();
1211
1326
  try {
@@ -1214,9 +1329,26 @@ export async function processQuestionViaOrchestrator(question, dataProductCoordi
1214
1329
  addThinkingStep(setMessages, `Found ${resolvedEntities.relatedEntities.length} related entities`);
1215
1330
  }
1216
1331
  addThinkingStep(setMessages, 'Generating Legend query via orchestrator...');
1217
- const enrichedContext = modelContext
1332
+ const baseContext = modelContext
1218
1333
  ? buildEnrichedBusinessContext(question, resolvedEntities.rootEntity, resolvedEntities.relatedEntities, modelContext)
1219
1334
  : undefined;
1335
+ const priorFailureHints = priorFailure
1336
+ ? buildPriorSqlFailureHints(priorFailure.failedSql, priorFailure.failedReason)
1337
+ : [];
1338
+ const enrichedContext = priorFailureHints.length > 0
1339
+ ? {
1340
+ naturalLanguageQuery: question,
1341
+ ...baseContext,
1342
+ businessContextMatch: {
1343
+ ...baseContext?.businessContextMatch,
1344
+ additionalNlModelContext: [
1345
+ ...(baseContext?.businessContextMatch
1346
+ ?.additionalNlModelContext ?? []),
1347
+ ...priorFailureHints,
1348
+ ],
1349
+ },
1350
+ }
1351
+ : baseContext;
1220
1352
  const orchestratorResponse = await withTimeout(plugin.generateQueryViaOrchestrator({
1221
1353
  user_question: question,
1222
1354
  semantic_search_resolution_details: {
@@ -1516,9 +1648,16 @@ export function ensureSafeLimit(sql, limit = DEFAULT_SAFETY_LIMIT) {
1516
1648
  }
1517
1649
  return `${sql.trimEnd()}\nLIMIT ${limit}`;
1518
1650
  }
1651
+ /**
1652
+ * The one place generated SQL text is rewritten, working around engine
1653
+ * transpiler limits on joins. Every step no-ops on a shape it cannot fix.
1654
+ */
1519
1655
  function prepareSafeSql(sql, services) {
1520
- const joined = sanitizeLiteralColumns(sanitizeJoinSameKeyColumns(sanitizeJoinOrderBy(sanitizeJoinDuplicateColumns(sql, services)), services));
1521
- const wrapped = wrapBareJoinAccessPoints(joined, services);
1656
+ const deduped = sanitizeJoinDuplicateColumns(sql, services);
1657
+ const ordered = sanitizeJoinOrderBy(deduped);
1658
+ const keyed = sanitizeJoinSameKeyColumns(ordered, services);
1659
+ const literals = sanitizeLiteralColumns(keyed);
1660
+ const wrapped = wrapBareJoinAccessPoints(literals, services);
1522
1661
  const bounded = boundCrossAccessPointJoinDrivingSide(wrapped);
1523
1662
  const safeSql = ensureSafeLimit(ensureDateParameters(bounded, services));
1524
1663
  const unsupported = detectUnsupportedEnginePattern(safeSql);
@@ -1670,10 +1809,9 @@ function handleSqlGenerationFailure(setMessages, startTime, hasOrchestratorFallb
1670
1809
  updateLastAssistant(setMessages, () => ({
1671
1810
  textAnswer: orchestratorMessage,
1672
1811
  suggestedQueries: suggestions,
1673
- fallbackAction: {
1674
- label: ORCHESTRATOR_FALLBACK_LABEL,
1675
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
1676
- },
1812
+ fallbackAction: buildOrchestratorFallbackAction({
1813
+ failedReason: orchestratorMessage,
1814
+ }),
1677
1815
  isProcessing: false,
1678
1816
  thinkingDuration: elapsedSeconds(startTime),
1679
1817
  }));
@@ -1688,7 +1826,7 @@ function handleSqlGenerationFailure(setMessages, startTime, hasOrchestratorFallb
1688
1826
  }
1689
1827
  }
1690
1828
  async function reportQueryResults(report, metadata, context, startTime, hasOrchestratorFallback) {
1691
- const { currentSql, sqlResult, question, services, allAccessPoints } = report;
1829
+ const { currentSql, sqlResult, question, services, allAccessPoints, zeroRowExplanation, } = report;
1692
1830
  const { config, plugin, setMessages } = context;
1693
1831
  if (sqlResult.rows.length > 0) {
1694
1832
  const columns = deduplicateColumns(sqlResult.columns);
@@ -1723,13 +1861,16 @@ async function reportQueryResults(report, metadata, context, startTime, hasOrche
1723
1861
  }
1724
1862
  }
1725
1863
  else {
1726
- addThinkingStep(setMessages, 'Query returned 0 rows after correction attempts.');
1727
- const joinExplanation = buildCrossJoinZeroRowExplanation(currentSql, services);
1864
+ addThinkingStep(setMessages, zeroRowExplanation === undefined
1865
+ ? 'Query returned 0 rows after correction attempts.'
1866
+ : 'No overlapping join keys — the query would return 0 rows.');
1867
+ const joinExplanation = zeroRowExplanation ??
1868
+ buildCrossJoinZeroRowExplanation(currentSql, services);
1728
1869
  const joinSuggestions = joinExplanation === undefined
1729
1870
  ? []
1730
1871
  : buildJoinablePairSuggestions(services, allAccessPoints ?? services);
1731
1872
  const llmAnalysis = await withTimeout(plugin
1732
- .buildNoResultsFallback(question, currentSql, metadata, config, joinExplanation)
1873
+ .buildNoResultsFallback(question, currentSql, metadata, config, joinExplanation, services)
1733
1874
  .catch((error) => {
1734
1875
  assertErrorThrown(error);
1735
1876
  return undefined;
@@ -1738,18 +1879,17 @@ async function reportQueryResults(report, metadata, context, startTime, hasOrche
1738
1879
  const suggestions = joinSuggestions.length > 0
1739
1880
  ? joinSuggestions
1740
1881
  : (llmAnalysis?.suggestedQueries ?? []);
1882
+ const zeroRowReason = llmAnalysis?.summary ?? joinExplanation ?? buildZeroRowMessage(services);
1741
1883
  const fallback = hasOrchestratorFallback
1742
1884
  ? {
1743
- fallbackAction: {
1744
- label: ORCHESTRATOR_FALLBACK_LABEL,
1745
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
1746
- },
1885
+ fallbackAction: buildOrchestratorFallbackAction({
1886
+ failedSql: currentSql,
1887
+ failedReason: zeroRowReason,
1888
+ }),
1747
1889
  }
1748
1890
  : {};
1749
1891
  updateLastAssistant(setMessages, () => ({
1750
- textAnswer: llmAnalysis?.summary ??
1751
- joinExplanation ??
1752
- buildZeroRowMessage(services),
1892
+ textAnswer: zeroRowReason,
1753
1893
  ...(suggestions.length > 0 ? { suggestedQueries: suggestions } : {}),
1754
1894
  ...fallback,
1755
1895
  isProcessing: false,
@@ -1864,11 +2004,11 @@ export function applyMultiTurnBias(services, history) {
1864
2004
  }
1865
2005
  // Sort so that previously-used APs come first, preserving relative order
1866
2006
  const biased = [...services].sort((a, b) => {
1867
- const aUsed = previousAPs.has(a.pattern.replace(/^\//, '').toLowerCase()) ||
2007
+ const aUsed = previousAPs.has(accessPointName(a).toLowerCase()) ||
1868
2008
  previousAPs.has(a.title.toLowerCase())
1869
2009
  ? 1
1870
2010
  : 0;
1871
- const bUsed = previousAPs.has(b.pattern.replace(/^\//, '').toLowerCase()) ||
2011
+ const bUsed = previousAPs.has(accessPointName(b).toLowerCase()) ||
1872
2012
  previousAPs.has(b.title.toLowerCase())
1873
2013
  ? 1
1874
2014
  : 0;
@@ -2128,43 +2268,37 @@ function buildAccessPointColumnIndex(services) {
2128
2268
  }
2129
2269
  return index;
2130
2270
  }
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) {
2271
+ // Builds a memoized distinct-value prober for a query's columns: access-point
2272
+ // columns resolve via a relation query, service columns via SELECT DISTINCT.
2273
+ export function createColumnValueProbe(services, dataProductCoordinates, fromClause, isAccessPointProbe, context, options) {
2134
2274
  const { plugin, config, setMessages } = context;
2135
- const isAccessPointProbe = dataProductCoordinates !== undefined &&
2136
- selectedServices.some((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT);
2275
+ const rowLimit = options?.rowLimit ?? DISTINCT_PROBE_ROW_LIMIT;
2276
+ const maxColumns = options?.maxColumns ?? MAX_RECOVERED_FILTER_COLUMNS;
2137
2277
  const accessPointByColumn = isAccessPointProbe
2138
- ? buildAccessPointColumnIndex(selectedServices)
2278
+ ? buildAccessPointColumnIndex(services)
2139
2279
  : new Map();
2140
- const fromClause = isAccessPointProbe
2141
- ? undefined
2142
- : extractFromClause(currentSql);
2143
- if (!isAccessPointProbe && fromClause === undefined) {
2144
- return undefined;
2145
- }
2146
2280
  const probeAccessPointColumn = async (column) => {
2147
2281
  const accessPoint = accessPointByColumn.get(column.toLowerCase());
2148
2282
  const pId = accessPoint ? servicePId(accessPoint) : undefined;
2149
2283
  if (pId === undefined) {
2150
2284
  return { columns: [], rows: [] };
2151
2285
  }
2152
- const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${DISTINCT_PROBE_ROW_LIMIT})`;
2286
+ const relationQuery = `#P{${pId}}#->select(~[${pureRelationColumnRef(column)}])->distinct()->take(${rowLimit})`;
2153
2287
  return plugin.executeLakehouseRelationQuery(relationQuery, guaranteeNonNullable(dataProductCoordinates), config);
2154
2288
  };
2155
2289
  const probedValues = new Map();
2156
- const probeColumnValues = async (column) => {
2290
+ return async (column) => {
2157
2291
  if (probedValues.has(column)) {
2158
2292
  return probedValues.get(column);
2159
2293
  }
2160
- if (probedValues.size >= MAX_RECOVERED_FILTER_COLUMNS) {
2294
+ if (probedValues.size >= maxColumns) {
2161
2295
  return undefined;
2162
2296
  }
2163
2297
  let values;
2164
2298
  try {
2165
2299
  const probe = isAccessPointProbe
2166
2300
  ? await probeAccessPointColumn(column)
2167
- : await executeSqlForServices(`SELECT DISTINCT "${column}" ${fromClause} LIMIT ${DISTINCT_PROBE_ROW_LIMIT}`, selectedServices, dataProductCoordinates, plugin, config);
2301
+ : await executeSqlForServices(`SELECT DISTINCT "${column}" ${fromClause} LIMIT ${rowLimit}`, services, dataProductCoordinates, plugin, config);
2168
2302
  values = probe.rows
2169
2303
  .map((row) => row[column])
2170
2304
  .filter(isNonNullable)
@@ -2172,12 +2306,92 @@ async function attemptDistinctValueRecovery(currentSql, selectedServices, dataPr
2172
2306
  }
2173
2307
  catch (probeError) {
2174
2308
  assertErrorThrown(probeError);
2175
- addThinkingStep(setMessages, `Could not probe values for "${column}"`);
2309
+ if (options?.silent !== true) {
2310
+ addThinkingStep(setMessages, `Could not probe values for "${column}"`);
2311
+ }
2176
2312
  values = undefined;
2177
2313
  }
2178
2314
  probedValues.set(column, values);
2179
2315
  return values;
2180
2316
  };
2317
+ }
2318
+ // An empty inner join yields no rows, but an outer join, an aggregate, a
2319
+ // disjunctive predicate or a set operation can still return some.
2320
+ export function canSkipExecutionOnEmptyJoin(sql) {
2321
+ return (!OUTER_JOIN_PATTERN.test(sql) &&
2322
+ !HAS_AGGREGATION_PATTERN.test(sql) &&
2323
+ !DISJUNCTIVE_PREDICATE_PATTERN.test(sql) &&
2324
+ !SET_OPERATION_PATTERN.test(sql));
2325
+ }
2326
+ /**
2327
+ * Reports the join key whose values do not overlap, so a two access point join
2328
+ * can be explained instead of run. Text keys only, because dates and numbers
2329
+ * serialize differently on either side and would read as disjoint when they
2330
+ * are not, and only for exactly two access points, because a key belonging to
2331
+ * another pair would otherwise be tested against the wrong relations.
2332
+ */
2333
+ export async function probeJoinOverlap(sql, services, joinKeys, dataProductCoordinates, context) {
2334
+ if (dataProductCoordinates === undefined) {
2335
+ return undefined;
2336
+ }
2337
+ const involved = resolveJoinedAccessPoints(sql, services);
2338
+ const apA = involved[0];
2339
+ const apB = involved[1];
2340
+ if (involved.length !== 2 || apA === undefined || apB === undefined) {
2341
+ return undefined;
2342
+ }
2343
+ const sharedKeys = sharedColumnNames(apA, apB)
2344
+ .filter((name) => joinKeys.has(name.toLowerCase()))
2345
+ .slice(0, MAX_JOIN_OVERLAP_PROBE_KEYS);
2346
+ if (sharedKeys.length === 0) {
2347
+ return undefined;
2348
+ }
2349
+ const columnsA = buildColumnByNameIndex(apA.columns);
2350
+ const columnsB = buildColumnByNameIndex(apB.columns);
2351
+ const probeOptions = {
2352
+ rowLimit: JOIN_OVERLAP_PROBE_LIMIT,
2353
+ maxColumns: MAX_JOIN_OVERLAP_PROBE_KEYS,
2354
+ silent: true,
2355
+ };
2356
+ const probeA = createColumnValueProbe([apA], dataProductCoordinates, undefined, true, context, probeOptions);
2357
+ const probeB = createColumnValueProbe([apB], dataProductCoordinates, undefined, true, context, probeOptions);
2358
+ const fullyEnumerated = (values) => values !== undefined &&
2359
+ values.length > 0 &&
2360
+ values.length < JOIN_OVERLAP_CONCLUSIVE_MAX;
2361
+ for (const key of sharedKeys) {
2362
+ const columnA = columnsA.get(key.toLowerCase());
2363
+ const columnB = columnsB.get(key.toLowerCase());
2364
+ if (columnA === undefined || columnB === undefined) {
2365
+ continue;
2366
+ }
2367
+ const nameB = columnB.name;
2368
+ if (!isStringTypedColumn(columnA) || !isStringTypedColumn(columnB)) {
2369
+ continue;
2370
+ }
2371
+ const [valuesA, valuesB] = await Promise.all([probeA(key), probeB(nameB)]);
2372
+ if (!fullyEnumerated(valuesA) || !fullyEnumerated(valuesB)) {
2373
+ continue;
2374
+ }
2375
+ const setB = new Set(valuesB.map((v) => v.toLowerCase()));
2376
+ if (!valuesA.some((v) => setB.has(v.toLowerCase()))) {
2377
+ return buildDisjointJoinMessage(apA.title, apB.title, key, previewValues(valuesA), previewValues(valuesB));
2378
+ }
2379
+ }
2380
+ return undefined;
2381
+ }
2382
+ // Recovers a zero-row query by re-probing each WHERE-filter column's real distinct
2383
+ // values and rewriting equality/IN literals that don't match (casing/spelling).
2384
+ async function attemptDistinctValueRecovery(currentSql, selectedServices, dataProductCoordinates, context) {
2385
+ const { plugin, config, setMessages } = context;
2386
+ const isAccessPointProbe = dataProductCoordinates !== undefined &&
2387
+ selectedServices.some((s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT);
2388
+ const fromClause = isAccessPointProbe
2389
+ ? undefined
2390
+ : extractFromClause(currentSql);
2391
+ if (!isAccessPointProbe && fromClause === undefined) {
2392
+ return undefined;
2393
+ }
2394
+ const probeColumnValues = createColumnValueProbe(selectedServices, dataProductCoordinates, fromClause, isAccessPointProbe, context);
2181
2395
  await Promise.all(collectWhereFilterColumns(currentSql)
2182
2396
  .slice(0, MAX_RECOVERED_FILTER_COLUMNS)
2183
2397
  .map((column) => probeColumnValues(column)));
@@ -2343,14 +2557,14 @@ async function processAccessPointQuery(question, accessPoints, metadata, context
2343
2557
  const { config, plugin, setMessages } = context;
2344
2558
  addThinkingStep(setMessages, 'Found relevant access points to query');
2345
2559
  const selectedAPs = await selectBestServices(question, accessPoints, context);
2346
- const currentAccessPoints = new Set(selectedAPs.map((ap) => ap.pattern.replace(/^\//, '')));
2560
+ const currentAccessPoints = new Set(selectedAPs.map((ap) => accessPointName(ap)));
2347
2561
  const scopedContext = {
2348
2562
  ...context,
2349
2563
  history: filterHistoryForAccessPoints(context.history, currentAccessPoints),
2350
2564
  };
2351
2565
  try {
2352
2566
  addThinkingStep(setMessages, 'Grounding filter values from real data...');
2353
- await withTimeout(plugin.enrichAccessPointSampleValues(selectedAPs, dataProductCoordinates, config), VALUE_GROUNDING_TIME_BUDGET_MS);
2567
+ await withTimeout(plugin.enrichAccessPointSampleValues(selectedAPs, dataProductCoordinates, config, question), VALUE_GROUNDING_TIME_BUDGET_MS);
2354
2568
  }
2355
2569
  catch (error) {
2356
2570
  assertErrorThrown(error);
@@ -2368,9 +2582,34 @@ async function processAccessPointQuery(question, accessPoints, metadata, context
2368
2582
  updateLastAssistant(setMessages, () => ({
2369
2583
  sql: finalSql,
2370
2584
  sqlGenTime: sqlGenTimeValue,
2371
- isExecuting: true,
2372
2585
  queriedAccessPoints: orderedAccessPointsFromSql(finalSql),
2373
2586
  }));
2587
+ const joinKeys = canSkipExecutionOnEmptyJoin(finalSql)
2588
+ ? extractJoinKeyColumns(finalSql)
2589
+ : new Set();
2590
+ let disjointJoin = joinKeys.size > 0
2591
+ ? detectDisjointJoinUniverses(finalSql, selectedAPs, {
2592
+ requireComplete: true,
2593
+ restrictToColumns: joinKeys,
2594
+ })
2595
+ : undefined;
2596
+ if (disjointJoin === undefined && joinKeys.size > 0) {
2597
+ addThinkingStep(setMessages, 'Probing join-key overlap...');
2598
+ disjointJoin = await withTimeout(probeJoinOverlap(finalSql, selectedAPs, joinKeys, dataProductCoordinates, scopedContext), JOIN_OVERLAP_PROBE_TIMEOUT_MS);
2599
+ }
2600
+ if (disjointJoin !== undefined) {
2601
+ addThinkingStep(setMessages, 'Join keys do not overlap — skipping execution');
2602
+ await reportQueryResults({
2603
+ currentSql: finalSql,
2604
+ sqlResult: { columns: [], rows: [] },
2605
+ question,
2606
+ services: selectedAPs,
2607
+ allAccessPoints: accessPoints,
2608
+ zeroRowExplanation: disjointJoin,
2609
+ }, metadata, scopedContext, startTime, false);
2610
+ return;
2611
+ }
2612
+ updateLastAssistant(setMessages, () => ({ isExecuting: true }));
2374
2613
  const execStartTime = Date.now();
2375
2614
  try {
2376
2615
  let execSql = finalSql;
@@ -2428,13 +2667,22 @@ async function processDataQuery(question, services, coordinates, metadata, conte
2428
2667
  const missingParams = detectMissingServiceParams(judgedSql, selectedServices);
2429
2668
  if (missingParams.length > 0) {
2430
2669
  const sqlGenTimeValue = elapsedSeconds(startTime, 2);
2670
+ const missingParamsReason = `Missing required parameter${missingParams.length > 1 ? 's' : ''}: ${missingParams.map((p) => p.name).join(', ')}`;
2431
2671
  completeThinkingSteps(setMessages);
2432
- addThinkingStep(setMessages, `Missing required parameter${missingParams.length > 1 ? 's' : ''}: ${missingParams.map((p) => p.name).join(', ')}`);
2672
+ addThinkingStep(setMessages, missingParamsReason);
2433
2673
  completeThinkingSteps(setMessages);
2434
2674
  updateLastAssistant(setMessages, () => ({
2435
2675
  sql: judgedSql,
2436
2676
  sqlGenTime: sqlGenTimeValue,
2437
2677
  textAnswer: buildMissingParamsWarning(missingParams),
2678
+ ...(hasOrchestratorFallback
2679
+ ? {
2680
+ fallbackAction: buildOrchestratorFallbackAction({
2681
+ failedSql: judgedSql,
2682
+ failedReason: missingParamsReason,
2683
+ }),
2684
+ }
2685
+ : {}),
2438
2686
  isProcessing: false,
2439
2687
  isExecuting: false,
2440
2688
  thinkingDuration: elapsedSeconds(startTime),
@@ -2450,9 +2698,10 @@ async function processDataQuery(question, services, coordinates, metadata, conte
2450
2698
  }));
2451
2699
  const execOutcome = await executeSqlWithRetries(judgedSql, question, selectedServices, coordinates, dataProductCoordinates, context);
2452
2700
  if (execOutcome.error) {
2453
- const execErrorType = classifyError(new Error(execOutcome.error));
2454
- addThinkingStep(setMessages, `Execution failed: ${execOutcome.error.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
2455
- finishWithThinkingError(setMessages, buildExecutionErrorMessage(execOutcome.error, selectedServices), startTime, execErrorType === LegendAIErrorType.GENERAL
2701
+ const execError = execOutcome.error;
2702
+ const execErrorType = classifyError(new Error(execError));
2703
+ addThinkingStep(setMessages, `Execution failed: ${execError.slice(0, MAX_THINKING_ERROR_PREVIEW_LENGTH)}`);
2704
+ finishWithThinkingError(setMessages, buildExecutionErrorMessage(execError, selectedServices), startTime, execErrorType === LegendAIErrorType.GENERAL
2456
2705
  ? LegendAIErrorType.EXECUTION
2457
2706
  : execErrorType);
2458
2707
  updateLastAssistant(setMessages, () => ({
@@ -2460,10 +2709,10 @@ async function processDataQuery(question, services, coordinates, metadata, conte
2460
2709
  suggestedQueries: buildFallbackSuggestions(selectedServices),
2461
2710
  ...(hasOrchestratorFallback
2462
2711
  ? {
2463
- fallbackAction: {
2464
- label: ORCHESTRATOR_FALLBACK_LABEL,
2465
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2466
- },
2712
+ fallbackAction: buildOrchestratorFallbackAction({
2713
+ failedSql: execOutcome.sql,
2714
+ failedReason: execError,
2715
+ }),
2467
2716
  }
2468
2717
  : {}),
2469
2718
  }));
@@ -2549,10 +2798,7 @@ export async function processQuestion(question, services, coordinates, metadata,
2549
2798
  await handleMetadataQuestion(question, metadata, context, startTime, false, services, getMetadataEnrichment());
2550
2799
  if (config.orchestratorUrl && dataProductCoordinates) {
2551
2800
  updateLastAssistant(setMessages, () => ({
2552
- fallbackAction: {
2553
- label: ORCHESTRATOR_FALLBACK_LABEL,
2554
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2555
- },
2801
+ fallbackAction: buildOrchestratorFallbackAction(),
2556
2802
  }));
2557
2803
  }
2558
2804
  return;
@@ -2625,10 +2871,7 @@ export async function processQuestionWithIntent(question, intent, services, coor
2625
2871
  const startTime = Date.now();
2626
2872
  await handleMetadataQuestion(question, metadata, context, startTime, false, services, getMetadataEnrichment());
2627
2873
  updateLastAssistant(setMessages, () => ({
2628
- fallbackAction: {
2629
- label: ORCHESTRATOR_FALLBACK_LABEL,
2630
- actionId: LEGEND_AI_ORCHESTRATOR_FALLBACK_ACTION_ID,
2631
- },
2874
+ fallbackAction: buildOrchestratorFallbackAction(),
2632
2875
  }));
2633
2876
  return;
2634
2877
  }