@finos/legend-lego 2.0.202 → 2.0.203

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/lib/index.css +2 -2
  2. package/lib/index.css.map +1 -1
  3. package/lib/legend-ai/LegendAIDocEnrichment.d.ts +34 -1
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +890 -1
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts +36 -0
  8. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIServiceRetrieval.js +179 -0
  10. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -0
  11. package/lib/legend-ai/LegendAITypes.d.ts +62 -2
  12. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  13. package/lib/legend-ai/LegendAITypes.js +56 -30
  14. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  15. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +29 -6
  16. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  17. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +9 -0
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  19. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +5 -0
  20. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  21. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +18 -2
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  23. package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
  24. package/lib/legend-ai/components/LegendAIAnalysisPanel.js +27 -8
  25. package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
  26. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +1 -0
  27. package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
  28. package/lib/legend-ai/components/LegendAIAnalysisUtils.js +144 -22
  29. package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
  30. package/lib/legend-ai/components/LegendAICharts.d.ts +4 -0
  31. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  32. package/lib/legend-ai/components/LegendAICharts.js +60 -1
  33. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  34. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  35. package/lib/legend-ai/components/LegendAIChat.js +46 -17
  36. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  37. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +1 -1
  38. package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
  39. package/lib/legend-ai/components/LegendAIChatHelpers.js +220 -15
  40. package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
  41. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  42. package/lib/legend-ai/components/LegendAIChatInput.js +1 -0
  43. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  44. package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -1
  45. package/lib/legend-ai/components/LegendAIScopeSelector.js +5 -2
  46. package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -1
  47. package/lib/legend-ai/index.d.ts +4 -3
  48. package/lib/legend-ai/index.d.ts.map +1 -1
  49. package/lib/legend-ai/index.js +4 -3
  50. package/lib/legend-ai/index.js.map +1 -1
  51. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +30 -13
  52. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  53. package/lib/legend-ai/stores/LegendAIChatProcessors.js +776 -216
  54. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  55. package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -2
  56. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  57. package/lib/legend-ai/stores/LegendAIChatState.js +26 -25
  58. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/legend-ai/LegendAIDocEnrichment.ts +1198 -1
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +228 -0
  62. package/src/legend-ai/LegendAITypes.ts +138 -32
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +62 -0
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +28 -0
  65. package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +36 -17
  66. package/src/legend-ai/components/LegendAIAnalysisUtils.ts +178 -22
  67. package/src/legend-ai/components/LegendAICharts.tsx +160 -0
  68. package/src/legend-ai/components/LegendAIChat.tsx +107 -14
  69. package/src/legend-ai/components/LegendAIChatHelpers.ts +334 -20
  70. package/src/legend-ai/components/LegendAIChatInput.tsx +2 -1
  71. package/src/legend-ai/components/LegendAIScopeSelector.tsx +7 -2
  72. package/src/legend-ai/index.ts +22 -1
  73. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1205 -321
  74. package/src/legend-ai/stores/LegendAIChatState.ts +80 -69
  75. package/tsconfig.json +1 -0
@@ -31,7 +31,9 @@ import {
31
31
  LikeIcon,
32
32
  DislikeIcon,
33
33
  ExternalLinkIcon,
34
+ InfoCircleIcon,
34
35
  MarkdownTextViewer,
36
+ clsx,
35
37
  } from '@finos/legend-art';
36
38
  import { noop } from '@finos/legend-shared';
37
39
  import {
@@ -45,6 +47,7 @@ import {
45
47
  LegendAIThinkingStepStatus,
46
48
  LegendAIMessageRole,
47
49
  LegendAIErrorType,
50
+ TDSServiceSourceType,
48
51
  classifyQuestionIntentFast,
49
52
  } from '../LegendAITypes.js';
50
53
  import { useLegendAIChatState } from '../stores/LegendAIChatState.js';
@@ -56,6 +59,36 @@ import { buildSuggestedQueries } from './LegendAIChatHelpers.js';
56
59
  export const LEGEND_AI_ANCHOR_ID = 'legend-ai-anchor';
57
60
 
58
61
  const COPY_FEEDBACK_DURATION_MS = 2000;
62
+ const CONTEXT_BANNER_AUTO_DISMISS_MS = 20000;
63
+
64
+ const LegendAIContextBanner = (props: {
65
+ message: string;
66
+ onDismiss: () => void;
67
+ }): React.ReactNode => {
68
+ const { message, onDismiss } = props;
69
+
70
+ useEffect(() => {
71
+ const timer = setTimeout(onDismiss, CONTEXT_BANNER_AUTO_DISMISS_MS);
72
+ return (): void => clearTimeout(timer);
73
+ }, [onDismiss]);
74
+
75
+ return (
76
+ <div className="legend-ai__context-banner">
77
+ <div className="legend-ai__context-banner-icon">
78
+ <InfoCircleIcon />
79
+ </div>
80
+ <div className="legend-ai__context-banner-text">{message}</div>
81
+ <button
82
+ type="button"
83
+ className="legend-ai__context-banner-close"
84
+ aria-label="Dismiss"
85
+ onClick={onDismiss}
86
+ >
87
+ <TimesIcon />
88
+ </button>
89
+ </div>
90
+ );
91
+ };
59
92
  const METADATA_CONTEXT_HEADING = '### Metadata context';
60
93
  const QUERY_ANALYSIS_HEADING = '### Query analysis';
61
94
 
@@ -178,6 +211,21 @@ export function renderStepStatusIcon(
178
211
  );
179
212
  }
180
213
 
214
+ /**
215
+ * Determines whether an execution error message indicates an access/permission
216
+ * problem (as opposed to a SQL compilation or schema error). Only access-like
217
+ * errors should show the "Request Access" button.
218
+ */
219
+ const ACCESS_ERROR_PATTERN =
220
+ /insufficient privileges|access.*denied|permission.*denied|not authorized|unauthorized|403|entitlement/i;
221
+
222
+ function looksLikeAccessError(errorMsg: string | undefined): boolean {
223
+ if (!errorMsg) {
224
+ return false;
225
+ }
226
+ return ACCESS_ERROR_PATTERN.test(errorMsg);
227
+ }
228
+
181
229
  const AssistantMessageView = (props: {
182
230
  msg: LegendAIAssistantMessage;
183
231
  questionText: string;
@@ -192,6 +240,7 @@ const AssistantMessageView = (props: {
192
240
  onFallbackAction?: (messageId: string) => void;
193
241
  enghubDocUrl?: string;
194
242
  enthubRequestAccessUrl?: string;
243
+ onRequestAccess?: (accessPointGroupTitle: string) => void;
195
244
  }): React.ReactNode => {
196
245
  const {
197
246
  msg,
@@ -205,6 +254,7 @@ const AssistantMessageView = (props: {
205
254
  onFallbackAction,
206
255
  enghubDocUrl,
207
256
  enthubRequestAccessUrl,
257
+ onRequestAccess,
208
258
  } = props;
209
259
 
210
260
  const hasPermissionAccessLinks =
@@ -298,7 +348,10 @@ const AssistantMessageView = (props: {
298
348
  {visibleThinkingSteps.map((step) => (
299
349
  <div
300
350
  key={step.id}
301
- className={`legend-ai__thinking-step legend-ai__thinking-step--${step.status}`}
351
+ className={clsx(
352
+ 'legend-ai__thinking-step',
353
+ `legend-ai__thinking-step--${step.status}`,
354
+ )}
302
355
  >
303
356
  <span className="legend-ai__thinking-step-icon">
304
357
  {renderStepStatusIcon(step.status)}
@@ -396,6 +449,31 @@ const AssistantMessageView = (props: {
396
449
  </div>
397
450
  </div>
398
451
  )}
452
+ {msg.errorType === LegendAIErrorType.EXECUTION &&
453
+ onRequestAccess &&
454
+ msg.queriedAccessPointGroups.length > 0 &&
455
+ looksLikeAccessError(msg.error) && (
456
+ <div className="legend-ai__permission-error-action">
457
+ <span className="legend-ai__permission-error-note">
458
+ {msg.queriedAccessPointGroups.length === 1
459
+ ? 'You may not have access to this data. You can request access below.'
460
+ : `This query uses ${msg.queriedAccessPointGroups.length} access point groups. You can request access to each one below.`}
461
+ </span>
462
+ <div className="legend-ai__permission-error-btns">
463
+ {msg.queriedAccessPointGroups.map((apgTitle) => (
464
+ <button
465
+ key={apgTitle}
466
+ type="button"
467
+ className="legend-ai__permission-error-btn legend-ai__permission-error-btn--primary"
468
+ onClick={(): void => onRequestAccess(apgTitle)}
469
+ >
470
+ <ExternalLinkIcon />
471
+ <span>Request Access — {apgTitle}</span>
472
+ </button>
473
+ ))}
474
+ </div>
475
+ </div>
476
+ )}
399
477
  </div>
400
478
  )}
401
479
 
@@ -491,12 +569,11 @@ const AssistantMessageView = (props: {
491
569
  <div className="legend-ai__message-feedback-actions">
492
570
  <button
493
571
  type="button"
494
- className={`legend-ai__message-feedback-btn${
495
- selectedFeedbackRating ===
496
- LegendAIMessageFeedbackRating.THUMBS_UP
497
- ? 'legend-ai__message-feedback-btn--selected'
498
- : ''
499
- }`}
572
+ className={clsx('legend-ai__message-feedback-btn', {
573
+ 'legend-ai__message-feedback-btn--selected':
574
+ selectedFeedbackRating ===
575
+ LegendAIMessageFeedbackRating.THUMBS_UP,
576
+ })}
500
577
  title="Thumbs up"
501
578
  aria-label="Thumbs up"
502
579
  onClick={(): void =>
@@ -508,12 +585,11 @@ const AssistantMessageView = (props: {
508
585
  </button>
509
586
  <button
510
587
  type="button"
511
- className={`legend-ai__message-feedback-btn${
512
- selectedFeedbackRating ===
513
- LegendAIMessageFeedbackRating.THUMBS_DOWN
514
- ? 'legend-ai__message-feedback-btn--selected'
515
- : ''
516
- }`}
588
+ className={clsx('legend-ai__message-feedback-btn', {
589
+ 'legend-ai__message-feedback-btn--selected':
590
+ selectedFeedbackRating ===
591
+ LegendAIMessageFeedbackRating.THUMBS_DOWN,
592
+ })}
517
593
  title="Thumbs down"
518
594
  aria-label="Thumbs down"
519
595
  onClick={(): void =>
@@ -541,10 +617,13 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
541
617
  plugin,
542
618
  dataProductCoordinates,
543
619
  pureExecutionContext,
620
+ modelContext,
544
621
  availableScopes,
545
622
  onMessageFeedback,
546
623
  onClose,
547
624
  onMinimize,
625
+ onRequestAccess,
626
+ contextBannerMessage,
548
627
  } = props;
549
628
  const state = useLegendAIChatState(
550
629
  services,
@@ -554,6 +633,7 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
554
633
  plugin,
555
634
  dataProductCoordinates,
556
635
  pureExecutionContext,
636
+ modelContext,
557
637
  );
558
638
  const suggestedQueries = useMemo(
559
639
  () => buildSuggestedQueries(services, metadata),
@@ -566,8 +646,13 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
566
646
  classifyQuestionIntentFast(query, hasServices).intent,
567
647
  [hasServices],
568
648
  );
649
+ const isDataProduct = services.some(
650
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
651
+ );
652
+ const [showContextBanner, setShowContextBanner] = useState(true);
653
+ const dismissBanner = useCallback(() => setShowContextBanner(false), []);
569
654
  const hasMessages = state.messages.length > 0;
570
- const scopes = availableScopes ?? DEFAULT_SCOPES;
655
+ const scopes = isDataProduct ? [] : (availableScopes ?? DEFAULT_SCOPES);
571
656
  const [feedbackByMessageId, setFeedbackByMessageId] = useState<
572
657
  Map<string, LegendAIMessageFeedbackRating>
573
658
  >(new Map());
@@ -654,6 +739,13 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
654
739
  </div>
655
740
  </div>
656
741
 
742
+ {showContextBanner && contextBannerMessage && (
743
+ <LegendAIContextBanner
744
+ message={contextBannerMessage}
745
+ onDismiss={dismissBanner}
746
+ />
747
+ )}
748
+
657
749
  <div className="legend-ai__conversation" ref={state.conversationRef}>
658
750
  {!hasMessages && (
659
751
  <div className="legend-ai__empty-state">
@@ -716,6 +808,7 @@ export const LegendAIChat = (props: LegendAIChatProps): React.ReactNode => {
716
808
  {...(config.enthubRequestAccessUrl === undefined
717
809
  ? {}
718
810
  : { enthubRequestAccessUrl: config.enthubRequestAccessUrl })}
811
+ {...(onRequestAccess ? { onRequestAccess } : {})}
719
812
  onFallbackAction={(messageId): void =>
720
813
  state.runFallbackAction(messageId)
721
814
  }
@@ -14,69 +14,378 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { PRIMITIVE_TYPE } from '@finos/legend-graph';
18
- import type {
19
- LegendAIProductMetadata,
20
- TDSServiceSchema,
21
- TDSColumnSchema,
17
+ import {
18
+ PRECISE_PRIMITIVE_TYPE,
19
+ PRIMITIVE_TYPE,
20
+ extractElementNameFromPath,
21
+ } from '@finos/legend-graph';
22
+ import {
23
+ type LegendAIProductMetadata,
24
+ type TDSServiceSchema,
25
+ type TDSColumnSchema,
26
+ TDSServiceSourceType,
22
27
  } from '../LegendAITypes.js';
23
28
 
24
29
  const MAX_SUGGESTED_QUERIES = 8;
25
30
 
26
- const STRING_TYPES = new Set<string>([PRIMITIVE_TYPE.STRING]);
31
+ const STRING_TYPE_NAMES: ReadonlySet<string> = new Set([
32
+ PRIMITIVE_TYPE.STRING,
33
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.VARCHAR),
34
+ ]);
27
35
 
28
- const NUMERIC_TYPES = new Set<string>([
36
+ const NUMERIC_TYPE_NAMES: ReadonlySet<string> = new Set([
29
37
  PRIMITIVE_TYPE.NUMBER,
30
38
  PRIMITIVE_TYPE.INTEGER,
31
39
  PRIMITIVE_TYPE.FLOAT,
32
40
  PRIMITIVE_TYPE.DECIMAL,
41
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.INT),
42
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.TINY_INT),
43
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.U_TINY_INT),
44
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.SMALL_INT),
45
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.U_SMALL_INT),
46
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.U_INT),
47
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.BIG_INT),
48
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.U_BIG_INT),
49
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.FLOAT),
50
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.DOUBLE),
51
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.DECIMAL),
52
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.NUMERIC),
33
53
  ]);
34
54
 
35
- const DATE_TYPES = new Set<string>([
55
+ const DATE_TYPE_NAMES: ReadonlySet<string> = new Set([
36
56
  PRIMITIVE_TYPE.DATE,
37
57
  PRIMITIVE_TYPE.STRICTDATE,
38
58
  PRIMITIVE_TYPE.DATETIME,
59
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.STRICTDATE),
60
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.DATETIME),
61
+ extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.TIMESTAMP),
39
62
  ]);
40
63
 
64
+ const ID_COLUMN_PATTERN =
65
+ /(?:id|key|code|number|guid|kerberos|isin|cusip|account)$/i;
66
+ const CATEGORY_COLUMN_PATTERN =
67
+ /(?:type|name|region|division|department|unit|status|country|city|title|class|category|group|subdivision|organization|currency|location|sector|calendar|holiday|flag)/i;
68
+ const INFRASTRUCTURE_COLUMN_PATTERN =
69
+ /^lake_|^__lake|^vlf|^batch|_ap_lh_migration/i;
70
+ const AP_DATE_NAME_PATTERN = /(?:date|dt|time)$/i;
71
+
41
72
  export function isStringColumn(c: TDSColumnSchema): boolean {
42
- return STRING_TYPES.has(c.type ?? '') && !c.name.toLowerCase().includes('id');
73
+ return (
74
+ STRING_TYPE_NAMES.has(c.type ?? '') && !c.name.toLowerCase().includes('id')
75
+ );
43
76
  }
44
77
 
45
78
  export function isNumericColumn(c: TDSColumnSchema): boolean {
46
- return NUMERIC_TYPES.has(c.type ?? '');
79
+ return NUMERIC_TYPE_NAMES.has(c.type ?? '');
47
80
  }
48
81
 
49
82
  export function isDateColumn(c: TDSColumnSchema): boolean {
50
83
  return (
51
- DATE_TYPES.has(c.type ?? '') ||
84
+ DATE_TYPE_NAMES.has(c.type ?? '') ||
52
85
  c.name.toLowerCase().includes('date') ||
53
86
  c.name.toLowerCase().includes('time')
54
87
  );
55
88
  }
56
89
 
57
90
  function buildDataInsightSuggestions(
58
- primary: TDSServiceSchema,
91
+ svc: TDSServiceSchema,
59
92
  stringCol: TDSColumnSchema | undefined,
60
93
  numericCol: TDSColumnSchema | undefined,
61
94
  ): string[] {
62
95
  const result: string[] = [];
96
+ const dateCol = svc.columns.find(isDateColumn);
97
+ const allStringCols = svc.columns.filter(isStringColumn);
98
+ const allNumericCols = svc.columns.filter(isNumericColumn);
99
+
100
+ // Aggregation: top-N grouped by a category dimension
101
+ if (numericCol && stringCol) {
102
+ result.push(
103
+ `What are the top 10 ${stringCol.name} values by total ${numericCol.name} in ${svc.title}?`,
104
+ );
105
+ }
106
+
107
+ // Distribution / breakdown by category
63
108
  if (stringCol) {
64
109
  result.push(
65
- `Show the distinct ${stringCol.name} values from ${primary.title}`,
110
+ `Show the distribution of records by ${stringCol.name} in ${svc.title}`,
111
+ );
112
+ }
113
+
114
+ // Date-filtered query
115
+ if (dateCol && numericCol) {
116
+ result.push(
117
+ `Show ${numericCol.name} trends from the last 30 days in ${svc.title}`,
66
118
  );
119
+ } else if (dateCol) {
120
+ result.push(`Show the most recent 10 records from ${svc.title}`);
67
121
  }
68
122
 
69
- if (numericCol && !stringCol) {
70
- result.push(`What is the total ${numericCol.name} in ${primary.title}?`);
123
+ // Sample-value filter: use an actual sample value for concrete suggestions
124
+ if (stringCol?.sampleValues) {
125
+ const firstSample = stringCol.sampleValues.split(',')[0]?.trim();
126
+ if (firstSample && firstSample.length > 0) {
127
+ result.push(
128
+ `Show 10 records from ${svc.title} where ${stringCol.name} is ${firstSample}`,
129
+ );
130
+ }
71
131
  }
132
+
133
+ // Count distinct for a secondary grouping column
134
+ const secondaryString = allStringCols[1];
135
+ if (secondaryString) {
136
+ result.push(`Count records by ${secondaryString.name} in ${svc.title}`);
137
+ }
138
+
139
+ // Sum / average for a secondary numeric
140
+ const secondaryNumeric = allNumericCols[1];
141
+ if (secondaryNumeric && stringCol) {
142
+ result.push(
143
+ `What is the average ${secondaryNumeric.name} by ${stringCol.name} in ${svc.title}?`,
144
+ );
145
+ }
146
+
147
+ // Null analysis for nullable columns
148
+ const nullableCol = svc.columns.find(
149
+ (c) => c.nullable === true && isStringColumn(c),
150
+ );
151
+ if (nullableCol) {
152
+ result.push(
153
+ `How many records have missing ${nullableCol.name} in ${svc.title}?`,
154
+ );
155
+ }
156
+
72
157
  return result;
73
158
  }
74
159
 
75
- function buildMultiServiceSuggestion(services: TDSServiceSchema[]): string[] {
160
+ function isAPCategoryColumn(c: TDSColumnSchema): boolean {
161
+ return (
162
+ STRING_TYPE_NAMES.has(c.type ?? '') &&
163
+ !ID_COLUMN_PATTERN.test(c.name) &&
164
+ !INFRASTRUCTURE_COLUMN_PATTERN.test(c.name) &&
165
+ CATEGORY_COLUMN_PATTERN.test(c.name)
166
+ );
167
+ }
168
+
169
+ function isAPNumericColumn(c: TDSColumnSchema): boolean {
170
+ return NUMERIC_TYPE_NAMES.has(c.type ?? '');
171
+ }
172
+
173
+ function isAPDateColumn(c: TDSColumnSchema): boolean {
174
+ return DATE_TYPE_NAMES.has(c.type ?? '') || AP_DATE_NAME_PATTERN.test(c.name);
175
+ }
176
+
177
+ function isAPTextColumn(c: TDSColumnSchema): boolean {
178
+ return (
179
+ STRING_TYPE_NAMES.has(c.type ?? '') &&
180
+ !ID_COLUMN_PATTERN.test(c.name) &&
181
+ !INFRASTRUCTURE_COLUMN_PATTERN.test(c.name)
182
+ );
183
+ }
184
+
185
+ // ─── Access-Point Suggested Queries ─────────────────────────────────────────
186
+
187
+ function buildCategoryRichSuggestions(
188
+ primary: TDSServiceSchema,
189
+ groupCol: TDSColumnSchema,
190
+ filterCol: TDSColumnSchema | undefined,
191
+ numericCols: TDSColumnSchema[],
192
+ categoryCols: TDSColumnSchema[],
193
+ ): string[] {
194
+ const s: string[] = [
195
+ `How many records are in each ${groupCol.name} in ${primary.title}? Show top 10 by count`,
196
+ ];
197
+ if (filterCol) {
198
+ s.push(
199
+ `Which ${filterCol.name} values in ${primary.title} have more than 50 records?`,
200
+ );
201
+ }
202
+ if (numericCols[0]) {
203
+ s.push(
204
+ `What percentage of records in ${primary.title} belong to each ${groupCol.name}?`,
205
+ );
206
+ } else {
207
+ const secondGroupCol = categoryCols.find((c) => c !== groupCol);
208
+ if (secondGroupCol) {
209
+ s.push(
210
+ `Show the count by ${groupCol.name} and ${secondGroupCol.name} in ${primary.title}`,
211
+ );
212
+ }
213
+ }
214
+ return s;
215
+ }
216
+
217
+ function buildNumericHeavySuggestions(
218
+ primary: TDSServiceSchema,
219
+ textCols: TDSColumnSchema[],
220
+ numericCols: TDSColumnSchema[],
221
+ ): string[] {
222
+ const textCol = textCols[0];
223
+ const numCol = numericCols[0];
224
+ if (!textCol || !numCol) {
225
+ return [];
226
+ }
227
+ const s = [
228
+ `What are the top 10 ${textCol.name} values by total ${numCol.name} in ${primary.title}?`,
229
+ ];
230
+ if (numericCols[1]) {
231
+ s.push(
232
+ `Show the average ${numericCols[1].name} by ${textCol.name} in ${primary.title}`,
233
+ );
234
+ }
235
+ return s;
236
+ }
237
+
238
+ function buildDateCentricSuggestions(
239
+ primary: TDSServiceSchema,
240
+ dateCols: TDSColumnSchema[],
241
+ textCols: TDSColumnSchema[],
242
+ categoryCols: TDSColumnSchema[],
243
+ ): string[] {
244
+ const dateCol = dateCols[0];
245
+ if (!dateCol) {
246
+ return [];
247
+ }
248
+ const textCol = textCols[0] ?? categoryCols[0];
249
+ if (textCol) {
250
+ return [
251
+ `Show records from ${primary.title} for the current month by ${dateCol.name}`,
252
+ ];
253
+ }
254
+ return [
255
+ `Show the 20 most recent records from ${primary.title} by ${dateCol.name}`,
256
+ ];
257
+ }
258
+
259
+ function buildCrossAPSuggestions(
260
+ primary: TDSServiceSchema,
261
+ secondary: TDSServiceSchema,
262
+ currentCount: number,
263
+ ): string[] {
264
+ const primaryColNames = new Set(
265
+ primary.columns.map((c) => c.name.toLowerCase()),
266
+ );
267
+ const s: string[] = [];
268
+ const uniqueCol = secondary.columns.find(
269
+ (c) =>
270
+ !primaryColNames.has(c.name.toLowerCase()) &&
271
+ !INFRASTRUCTURE_COLUMN_PATTERN.test(c.name) &&
272
+ (isAPCategoryColumn(c) || isAPTextColumn(c)),
273
+ );
274
+ if (uniqueCol) {
275
+ s.push(
276
+ `For each record in ${primary.title}, get their ${uniqueCol.name} from ${secondary.title}`,
277
+ );
278
+ }
279
+ const sharedCat =
280
+ secondary.columns.find(isAPCategoryColumn) ??
281
+ secondary.columns.find(isAPTextColumn);
282
+ if (
283
+ sharedCat &&
284
+ primaryColNames.has(sharedCat.name.toLowerCase()) &&
285
+ currentCount + s.length < MAX_SUGGESTED_QUERIES
286
+ ) {
287
+ s.push(
288
+ `Compare the count of records by ${sharedCat.name} between ${primary.title} and ${secondary.title}`,
289
+ );
290
+ }
291
+ return s;
292
+ }
293
+
294
+ function buildAccessPointSuggestions(
295
+ accessPoints: TDSServiceSchema[],
296
+ metadata: LegendAIProductMetadata,
297
+ ): string[] {
298
+ const suggestions: string[] = [
299
+ `What data does ${metadata.name} offer and how can I use it?`,
300
+ ];
301
+ const primary = accessPoints[0];
302
+ if (!primary) {
303
+ return suggestions;
304
+ }
305
+ const categoryCols = primary.columns.filter(isAPCategoryColumn);
306
+ const numericCols = primary.columns.filter(isAPNumericColumn);
307
+ const dateCols = primary.columns.filter(isAPDateColumn);
308
+ const textCols = primary.columns.filter(isAPTextColumn);
309
+ const groupCol =
310
+ categoryCols.find((c) => /division|region|calendar|sector/i.test(c.name)) ??
311
+ categoryCols[0];
312
+ const filterCol = categoryCols.find(
313
+ (c) =>
314
+ c !== groupCol &&
315
+ /department|type|title|status|unit|holiday|flag|class|currency/i.test(
316
+ c.name,
317
+ ),
318
+ );
319
+
320
+ if (groupCol) {
321
+ suggestions.push(
322
+ ...buildCategoryRichSuggestions(
323
+ primary,
324
+ groupCol,
325
+ filterCol,
326
+ numericCols,
327
+ categoryCols,
328
+ ),
329
+ );
330
+ } else if (numericCols.length > 0 && textCols.length > 0) {
331
+ suggestions.push(
332
+ ...buildNumericHeavySuggestions(primary, textCols, numericCols),
333
+ );
334
+ } else if (dateCols.length > 0) {
335
+ suggestions.push(
336
+ ...buildDateCentricSuggestions(primary, dateCols, textCols, categoryCols),
337
+ );
338
+ } else if (textCols.length > 0) {
339
+ const col = textCols[0];
340
+ if (col) {
341
+ suggestions.push(
342
+ `Show all distinct ${col.name} values in ${primary.title}`,
343
+ );
344
+ }
345
+ } else {
346
+ suggestions.push(`Show 10 records from ${primary.title}`);
347
+ }
348
+
349
+ const dateCol0 = dateCols[0];
350
+ if (dateCol0 && groupCol && suggestions.length < 5) {
351
+ suggestions.push(
352
+ `Show the count of records by ${groupCol.name} for each ${dateCol0.name} in ${primary.title}`,
353
+ );
354
+ }
355
+
356
+ const secondary = accessPoints[1];
357
+ if (secondary) {
358
+ suggestions.push(
359
+ ...buildCrossAPSuggestions(primary, secondary, suggestions.length),
360
+ );
361
+ }
362
+
363
+ return suggestions.slice(0, MAX_SUGGESTED_QUERIES);
364
+ }
365
+
366
+ function buildCrossServiceSuggestions(services: TDSServiceSchema[]): string[] {
76
367
  if (services.length < 2) {
77
368
  return [];
78
369
  }
79
- return [`Show 10 records from ${services[1]?.title}`];
370
+ const result: string[] = [];
371
+ for (let i = 1; i < Math.min(services.length, 3); i++) {
372
+ const svc = services[i];
373
+ if (!svc) {
374
+ continue;
375
+ }
376
+ const numCol = svc.columns.find(isNumericColumn);
377
+ const strCol = svc.columns.find(isStringColumn);
378
+ if (numCol && strCol) {
379
+ result.push(
380
+ `What are the top 5 ${strCol.name} values by ${numCol.name} in ${svc.title}?`,
381
+ );
382
+ } else if (strCol) {
383
+ result.push(`Show the breakdown by ${strCol.name} in ${svc.title}`);
384
+ } else {
385
+ result.push(`Show 10 records from ${svc.title}`);
386
+ }
387
+ }
388
+ return result;
80
389
  }
81
390
 
82
391
  export function buildSuggestedQueries(
@@ -95,6 +404,13 @@ export function buildSuggestedQueries(
95
404
  ];
96
405
  }
97
406
 
407
+ const allAccessPoints = services.every(
408
+ (s) => s.sourceType === TDSServiceSourceType.ACCESS_POINT,
409
+ );
410
+ if (allAccessPoints) {
411
+ return buildAccessPointSuggestions(services, metadata);
412
+ }
413
+
98
414
  const primary = services[0];
99
415
  if (!primary) {
100
416
  return [
@@ -106,12 +422,10 @@ export function buildSuggestedQueries(
106
422
  const stringCol = primary.columns.find(isStringColumn);
107
423
  const numericCol = primary.columns.find(isNumericColumn);
108
424
 
109
- const multiSvcSuggestions = buildMultiServiceSuggestion(services);
110
-
111
425
  return [
112
426
  ...suggestions,
113
427
  `Show 10 records from ${primary.title}`,
114
428
  ...buildDataInsightSuggestions(primary, stringCol, numericCol),
115
- ...multiSvcSuggestions,
429
+ ...buildCrossServiceSuggestions(services),
116
430
  ].slice(0, MAX_SUGGESTED_QUERIES);
117
431
  }
@@ -52,7 +52,8 @@ export const LegendAIChatInput = (props: {
52
52
  const handleClickOutside = (event: MouseEvent): void => {
53
53
  if (
54
54
  modelDropdownRef.current &&
55
- !modelDropdownRef.current.contains(event.target as Node)
55
+ event.target instanceof Node &&
56
+ !modelDropdownRef.current.contains(event.target)
56
57
  ) {
57
58
  setIsModelDropdownOpen(false);
58
59
  }
@@ -20,6 +20,7 @@ import {
20
20
  SearchIcon,
21
21
  CheckIcon,
22
22
  TimesIcon,
23
+ clsx,
23
24
  } from '@finos/legend-art';
24
25
  import type { LegendAIScopeItem } from '../LegendAITypes.js';
25
26
 
@@ -70,7 +71,8 @@ export const LegendAIScopeSelector = (props: {
70
71
  const handleClickOutside = (e: MouseEvent): void => {
71
72
  if (
72
73
  dropdownRef.current &&
73
- !dropdownRef.current.contains(e.target as Node)
74
+ e.target instanceof Node &&
75
+ !dropdownRef.current.contains(e.target)
74
76
  ) {
75
77
  setDropdownOpen(false);
76
78
  setSearchText('');
@@ -172,7 +174,10 @@ export const LegendAIScopeSelector = (props: {
172
174
  key={scope.id}
173
175
  type="button"
174
176
  aria-pressed={isSelected}
175
- className={`legend-ai__scope-dropdown-item${isSelected ? 'legend-ai__scope-dropdown-item--selected' : ''}`}
177
+ className={clsx('legend-ai__scope-dropdown-item', {
178
+ 'legend-ai__scope-dropdown-item--selected':
179
+ isSelected,
180
+ })}
176
181
  onClick={(): void => {
177
182
  onToggleScope(scope);
178
183
  setDropdownOpen(false);