@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.
- package/lib/index.css +2 -2
- package/lib/index.css.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts +34 -1
- package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
- package/lib/legend-ai/LegendAIDocEnrichment.js +890 -1
- package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
- package/lib/legend-ai/LegendAIServiceRetrieval.d.ts +36 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.js +179 -0
- package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -0
- package/lib/legend-ai/LegendAITypes.d.ts +62 -2
- package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
- package/lib/legend-ai/LegendAITypes.js +56 -30
- package/lib/legend-ai/LegendAITypes.js.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +29 -6
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +9 -0
- package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts +5 -0
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +18 -2
- package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisPanel.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisPanel.js +27 -8
- package/lib/legend-ai/components/LegendAIAnalysisPanel.js.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts +1 -0
- package/lib/legend-ai/components/LegendAIAnalysisUtils.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIAnalysisUtils.js +144 -22
- package/lib/legend-ai/components/LegendAIAnalysisUtils.js.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.d.ts +4 -0
- package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAICharts.js +60 -1
- package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChat.js +46 -17
- package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.d.ts +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChatHelpers.js +220 -15
- package/lib/legend-ai/components/LegendAIChatHelpers.js.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIChatInput.js +1 -0
- package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
- package/lib/legend-ai/components/LegendAIScopeSelector.d.ts.map +1 -1
- package/lib/legend-ai/components/LegendAIScopeSelector.js +5 -2
- package/lib/legend-ai/components/LegendAIScopeSelector.js.map +1 -1
- package/lib/legend-ai/index.d.ts +4 -3
- package/lib/legend-ai/index.d.ts.map +1 -1
- package/lib/legend-ai/index.js +4 -3
- package/lib/legend-ai/index.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +30 -13
- package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatProcessors.js +776 -216
- package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.d.ts +2 -2
- package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
- package/lib/legend-ai/stores/LegendAIChatState.js +26 -25
- package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
- package/package.json +3 -3
- package/src/legend-ai/LegendAIDocEnrichment.ts +1198 -1
- package/src/legend-ai/LegendAIServiceRetrieval.ts +228 -0
- package/src/legend-ai/LegendAITypes.ts +138 -32
- package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +62 -0
- package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +28 -0
- package/src/legend-ai/components/LegendAIAnalysisPanel.tsx +36 -17
- package/src/legend-ai/components/LegendAIAnalysisUtils.ts +178 -22
- package/src/legend-ai/components/LegendAICharts.tsx +160 -0
- package/src/legend-ai/components/LegendAIChat.tsx +107 -14
- package/src/legend-ai/components/LegendAIChatHelpers.ts +334 -20
- package/src/legend-ai/components/LegendAIChatInput.tsx +2 -1
- package/src/legend-ai/components/LegendAIScopeSelector.tsx +7 -2
- package/src/legend-ai/index.ts +22 -1
- package/src/legend-ai/stores/LegendAIChatProcessors.ts +1205 -321
- package/src/legend-ai/stores/LegendAIChatState.ts +80 -69
- 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={
|
|
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={
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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={
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
31
|
+
const STRING_TYPE_NAMES: ReadonlySet<string> = new Set([
|
|
32
|
+
PRIMITIVE_TYPE.STRING,
|
|
33
|
+
extractElementNameFromPath(PRECISE_PRIMITIVE_TYPE.VARCHAR),
|
|
34
|
+
]);
|
|
27
35
|
|
|
28
|
-
const
|
|
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
|
|
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
|
|
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
|
|
79
|
+
return NUMERIC_TYPE_NAMES.has(c.type ?? '');
|
|
47
80
|
}
|
|
48
81
|
|
|
49
82
|
export function isDateColumn(c: TDSColumnSchema): boolean {
|
|
50
83
|
return (
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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);
|