@finos/legend-lego 2.0.202 → 2.0.204

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 +5 -5
  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
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Copyright (c) 2026-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { guaranteeNonNullable } from '@finos/legend-shared';
18
+ import type { TDSServiceSchema } from './LegendAITypes.js';
19
+ import {
20
+ splitIdentifierTokens,
21
+ tokenizeText,
22
+ } from './LegendAIDocEnrichment.js';
23
+
24
+ const BM25_K1 = 1.2;
25
+ const BM25_B = 0.75;
26
+ const FIELD_WEIGHT_TITLE = 3;
27
+ const FIELD_WEIGHT_DESCRIPTION = 2;
28
+ const MIN_FUZZY_TOKEN_LENGTH = 4;
29
+
30
+ interface BM25Doc {
31
+ termFreq: Map<string, number>;
32
+ length: number;
33
+ }
34
+
35
+ interface BM25Index {
36
+ docs: BM25Doc[];
37
+ docFreq: Map<string, number>;
38
+ avgLength: number;
39
+ }
40
+
41
+ function tokensFromIdentifier(name: string): string[] {
42
+ return [...tokenizeText(name), ...splitIdentifierTokens(name)];
43
+ }
44
+
45
+ function addTokensToDoc(doc: BM25Doc, tokens: string[], weight: number): void {
46
+ for (const token of tokens) {
47
+ doc.termFreq.set(token, (doc.termFreq.get(token) ?? 0) + weight);
48
+ doc.length += weight;
49
+ }
50
+ }
51
+
52
+ function buildServiceDoc(svc: TDSServiceSchema): BM25Doc {
53
+ const doc: BM25Doc = { termFreq: new Map(), length: 0 };
54
+
55
+ addTokensToDoc(doc, tokensFromIdentifier(svc.title), FIELD_WEIGHT_TITLE);
56
+ if (svc.description) {
57
+ addTokensToDoc(
58
+ doc,
59
+ tokenizeText(svc.description),
60
+ FIELD_WEIGHT_DESCRIPTION,
61
+ );
62
+ }
63
+ for (const col of svc.columns) {
64
+ addTokensToDoc(doc, tokensFromIdentifier(col.name), 1);
65
+ if (col.documentation) {
66
+ addTokensToDoc(doc, tokenizeText(col.documentation), 1);
67
+ }
68
+ }
69
+ for (const param of svc.parameters) {
70
+ addTokensToDoc(doc, tokensFromIdentifier(param), 1);
71
+ }
72
+ for (const pf of svc.preFilters ?? []) {
73
+ addTokensToDoc(doc, tokenizeText(pf.property), 1);
74
+ if (pf.value !== undefined) {
75
+ addTokensToDoc(doc, tokenizeText(String(pf.value)), 1);
76
+ }
77
+ }
78
+ return doc;
79
+ }
80
+
81
+ function buildBM25Index(services: readonly TDSServiceSchema[]): BM25Index {
82
+ const docs = services.map(buildServiceDoc);
83
+ const docFreq = new Map<string, number>();
84
+ let totalLength = 0;
85
+ for (const doc of docs) {
86
+ totalLength += doc.length;
87
+ for (const term of doc.termFreq.keys()) {
88
+ docFreq.set(term, (docFreq.get(term) ?? 0) + 1);
89
+ }
90
+ }
91
+ return {
92
+ docs,
93
+ docFreq,
94
+ avgLength: docs.length === 0 ? 0 : totalLength / docs.length,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Standard Okapi BM25:
100
+ * IDF(q) = ln((N - df + 0.5) / (df + 0.5) + 1)
101
+ * tfNorm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * |D| / avgdl))
102
+ * score = Σ IDF(q) * tfNorm
103
+ */
104
+ function scoreBM25(
105
+ queryTerms: string[],
106
+ doc: BM25Doc,
107
+ index: BM25Index,
108
+ ): number {
109
+ const n = index.docs.length;
110
+ const avgdl = index.avgLength === 0 ? 1 : index.avgLength;
111
+ let score = 0;
112
+ for (const term of queryTerms) {
113
+ const tf = doc.termFreq.get(term);
114
+ if (!tf) {
115
+ continue;
116
+ }
117
+ const df = index.docFreq.get(term) ?? 0;
118
+ const idf = Math.log((n - df + 0.5) / (df + 0.5) + 1);
119
+ const tfNorm =
120
+ (tf * (BM25_K1 + 1)) /
121
+ (tf + BM25_K1 * (1 - BM25_B + (BM25_B * doc.length) / avgdl));
122
+ score += idf * tfNorm;
123
+ }
124
+ return score;
125
+ }
126
+
127
+ /** Rewrites a typoed query term to its closest indexed vocabulary entry. */
128
+ function resolveQueryTermViaFuzzy(token: string, index: BM25Index): string {
129
+ if (token.length < MIN_FUZZY_TOKEN_LENGTH || index.docFreq.has(token)) {
130
+ return token;
131
+ }
132
+ for (const term of index.docFreq.keys()) {
133
+ if (term.length >= MIN_FUZZY_TOKEN_LENGTH && isFuzzyMatch(token, term)) {
134
+ return term;
135
+ }
136
+ }
137
+ return token;
138
+ }
139
+
140
+ /**
141
+ * Lexical pre-filter that ranks services against the user question using
142
+ * Okapi BM25. IDF down-weights corpus-wide common terms and length
143
+ * normalization demotes denormalized aggregate tables — no stopword list
144
+ * or specificity penalty needed. Query typos are recovered via
145
+ * {@link resolveQueryTermViaFuzzy} before scoring.
146
+ */
147
+ export function preFilterServicesByRelevance(
148
+ question: string,
149
+ services: TDSServiceSchema[],
150
+ limit: number,
151
+ ): TDSServiceSchema[] {
152
+ const rawTerms = tokenizeText(question);
153
+ if (rawTerms.length === 0) {
154
+ return services.slice(0, limit);
155
+ }
156
+ const index = buildBM25Index(services);
157
+ const queryTerms = rawTerms.map((t) => resolveQueryTermViaFuzzy(t, index));
158
+ const scored = services.map((svc, i) => ({
159
+ svc,
160
+ score: scoreBM25(
161
+ queryTerms,
162
+ guaranteeNonNullable(index.docs[i], `BM25 doc missing for service ${i}`),
163
+ index,
164
+ ),
165
+ }));
166
+ scored.sort((a, b) => b.score - a.score);
167
+ return scored.slice(0, limit).map((s) => s.svc);
168
+ }
169
+
170
+ /**
171
+ * Returns `true` when two tokens are within Levenshtein edit distance 1 (for
172
+ * short tokens) or 2 (for longer tokens). Used to recover typoed query terms
173
+ * during retrieval and coverage checks.
174
+ */
175
+ export function isFuzzyMatch(a: string, b: string): boolean {
176
+ const lenDiff = Math.abs(a.length - b.length);
177
+ if (lenDiff > 2) {
178
+ return false;
179
+ }
180
+ const maxDist = Math.min(a.length, b.length) <= 4 ? 1 : 2;
181
+ return levenshteinDistance(a, b) <= maxDist;
182
+ }
183
+
184
+ /**
185
+ * Computes the Levenshtein edit distance between two strings.
186
+ * Uses a single-row DP approach for space efficiency.
187
+ */
188
+ export function levenshteinDistance(a: string, b: string): number {
189
+ if (a === b) {
190
+ return 0;
191
+ }
192
+ if (a.length === 0) {
193
+ return b.length;
194
+ }
195
+ if (b.length === 0) {
196
+ return a.length;
197
+ }
198
+ if (a.length > b.length) {
199
+ const tmp = a;
200
+ a = b;
201
+ b = tmp;
202
+ }
203
+
204
+ const aLen = a.length;
205
+ const bLen = b.length;
206
+ const row = new Array<number>(aLen + 1);
207
+
208
+ for (let i = 0; i <= aLen; i++) {
209
+ row[i] = i;
210
+ }
211
+
212
+ for (let j = 1; j <= bLen; j++) {
213
+ let prev = guaranteeNonNullable(row[0]);
214
+ row[0] = j;
215
+ for (let i = 1; i <= aLen; i++) {
216
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
217
+ const current = guaranteeNonNullable(row[i]);
218
+ row[i] = Math.min(
219
+ current + 1, // deletion
220
+ guaranteeNonNullable(row[i - 1]) + 1, // insertion
221
+ prev + cost, // substitution
222
+ );
223
+ prev = current;
224
+ }
225
+ }
226
+
227
+ return guaranteeNonNullable(row[aLen]);
228
+ }
@@ -209,6 +209,14 @@ export class LegendAIServiceError extends Error {
209
209
  }
210
210
  }
211
211
 
212
+ export class LegendAIUnsupportedEngineShapeError extends LegendAIServiceError {
213
+ override name = 'LegendAIUnsupportedEngineShapeError';
214
+
215
+ constructor(hint: string) {
216
+ super(hint, LegendAIErrorType.EXECUTION);
217
+ }
218
+ }
219
+
212
220
  export enum LegendAIMessageRole {
213
221
  USER = 'user',
214
222
  ASSISTANT = 'assistant',
@@ -256,6 +264,7 @@ export class LegendAIAssistantMessage {
256
264
  isExecuting!: boolean;
257
265
  suggestedQueries!: string[];
258
266
  fallbackAction!: LegendAIFallbackAction | null;
267
+ queriedAccessPointGroups!: string[];
259
268
  }
260
269
 
261
270
  export type LegendAIMessage = LegendAIUserMessage | LegendAIAssistantMessage;
@@ -366,6 +375,70 @@ export class LegendAIProductMetadata {
366
375
  domainContext?: string;
367
376
  }
368
377
 
378
+ // ────────────────────────────────────────────────────────────────────────────
379
+ // Model context for local entity resolution (DataSpaces only)
380
+ // ────────────────────────────────────────────────────────────────────────────
381
+
382
+ export interface LegendAIModelProperty {
383
+ name: string;
384
+ type: string;
385
+ isCollection: boolean;
386
+ isOptional: boolean;
387
+ }
388
+
389
+ export interface LegendAIModelEntity {
390
+ path: string;
391
+ name: string;
392
+ description?: string;
393
+ properties: LegendAIModelProperty[];
394
+ isRootMapped?: boolean;
395
+ isQueryable?: boolean;
396
+ superTypes?: string[];
397
+ }
398
+
399
+ export interface LegendAIModelAssociation {
400
+ name: string;
401
+ leftEntity: string;
402
+ leftProperty: string;
403
+ rightEntity: string;
404
+ rightProperty: string;
405
+ }
406
+
407
+ export interface LegendAIColumnPropertyMapping {
408
+ columnName: string;
409
+ propertyPath: string;
410
+ }
411
+
412
+ export interface LegendAIParameterInfo {
413
+ name: string;
414
+ type: string;
415
+ }
416
+
417
+ export interface LegendAIEnumerationInfo {
418
+ path: string;
419
+ name: string;
420
+ values: string[];
421
+ }
422
+
423
+ export interface LegendAIExecutableInfo {
424
+ title: string;
425
+ description?: string;
426
+ rootEntityPath: string;
427
+ referencedEntityPaths?: string[];
428
+ columns?: string[];
429
+ queryTemplate?: string;
430
+ requiredParameters?: LegendAIParameterInfo[];
431
+ columnPropertyMappings?: LegendAIColumnPropertyMapping[];
432
+ }
433
+
434
+ export interface LegendAIModelContext {
435
+ entities: LegendAIModelEntity[];
436
+ associations: LegendAIModelAssociation[];
437
+ enumerations?: LegendAIEnumerationInfo[];
438
+ executables?: LegendAIExecutableInfo[];
439
+ dataspaceDescription?: string;
440
+ }
441
+
369
442
  export enum LegendAIQuestionIntent {
370
443
  DATA_QUERY = 'data_query',
371
444
  METADATA = 'metadata',
@@ -474,6 +547,58 @@ export class QuestionIntentClassification {
474
547
  ambiguous!: boolean;
475
548
  }
476
549
 
550
+ function maskEntityNames(question: string, entityNames: string[]): string {
551
+ const escaped = entityNames
552
+ .filter((name) => name.length > 0)
553
+ .map((name) =>
554
+ name.toLowerCase().replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`),
555
+ );
556
+ if (escaped.length === 0) {
557
+ return question;
558
+ }
559
+ return question.replaceAll(new RegExp(escaped.join('|'), 'g'), '___');
560
+ }
561
+
562
+ function classifyMixedSignal(
563
+ q: string,
564
+ metaScore: number,
565
+ dataScore: number,
566
+ ): QuestionIntentClassification {
567
+ if (dataScore >= metaScore * 2) {
568
+ return {
569
+ intent: LegendAIQuestionIntent.DATA_QUERY,
570
+ metaScore,
571
+ dataScore,
572
+ ambiguous: false,
573
+ };
574
+ }
575
+ if (PRODUCT_REFERENCE_PATTERN.test(q) || STRUCTURAL_KEYWORD_PATTERN.test(q)) {
576
+ return {
577
+ intent: LegendAIQuestionIntent.METADATA,
578
+ metaScore,
579
+ dataScore,
580
+ ambiguous: false,
581
+ };
582
+ }
583
+ if (CAPABILITY_DISCOVERY_PATTERNS.some((p) => p.test(q))) {
584
+ return {
585
+ intent: LegendAIQuestionIntent.METADATA,
586
+ metaScore,
587
+ dataScore,
588
+ ambiguous: false,
589
+ };
590
+ }
591
+ return {
592
+ intent:
593
+ metaScore > dataScore
594
+ ? LegendAIQuestionIntent.METADATA
595
+ : LegendAIQuestionIntent.DATA_QUERY,
596
+ metaScore,
597
+ dataScore,
598
+ ambiguous: true,
599
+ };
600
+ }
601
+
477
602
  /**
478
603
  * Fast deterministic regex classifier (sync, < 1ms).
479
604
  * Returns both the resolved intent AND whether the result is ambiguous,
@@ -482,6 +607,7 @@ export class QuestionIntentClassification {
482
607
  export function classifyQuestionIntentFast(
483
608
  question: string,
484
609
  hasServices: boolean,
610
+ entityNames?: string[],
485
611
  ): QuestionIntentClassification {
486
612
  const q = question.toLowerCase().trim();
487
613
 
@@ -495,7 +621,12 @@ export function classifyQuestionIntentFast(
495
621
  }
496
622
 
497
623
  const metaScore = countPatternMatches(q, METADATA_SIGNAL_PATTERNS);
498
- const dataScore = countPatternMatches(q, DATA_QUERY_SIGNAL_PATTERNS);
624
+ const qForDataScore =
625
+ entityNames && entityNames.length > 0 ? maskEntityNames(q, entityNames) : q;
626
+ const dataScore = countPatternMatches(
627
+ qForDataScore,
628
+ DATA_QUERY_SIGNAL_PATTERNS,
629
+ );
499
630
 
500
631
  if (metaScore > 0 && dataScore === 0) {
501
632
  const isStructural =
@@ -518,37 +649,8 @@ export function classifyQuestionIntentFast(
518
649
  ambiguous: false,
519
650
  };
520
651
  }
521
-
522
652
  if (metaScore > 0 && dataScore > 0) {
523
- if (dataScore >= metaScore * 2) {
524
- return {
525
- intent: LegendAIQuestionIntent.DATA_QUERY,
526
- metaScore,
527
- dataScore,
528
- ambiguous: false,
529
- };
530
- }
531
- if (
532
- PRODUCT_REFERENCE_PATTERN.test(q) ||
533
- STRUCTURAL_KEYWORD_PATTERN.test(q)
534
- ) {
535
- return {
536
- intent: LegendAIQuestionIntent.METADATA,
537
- metaScore,
538
- dataScore,
539
- ambiguous: false,
540
- };
541
- }
542
- const tentative =
543
- metaScore > dataScore
544
- ? LegendAIQuestionIntent.METADATA
545
- : LegendAIQuestionIntent.DATA_QUERY;
546
- return {
547
- intent: tentative,
548
- metaScore,
549
- dataScore,
550
- ambiguous: true,
551
- };
653
+ return classifyMixedSignal(q, metaScore, dataScore);
552
654
  }
553
655
 
554
656
  return {
@@ -568,8 +670,9 @@ export function classifyQuestionIntentFast(
568
670
  export function classifyQuestionIntent(
569
671
  question: string,
570
672
  hasServices: boolean,
673
+ entityNames?: string[],
571
674
  ): LegendAIQuestionIntent {
572
- return classifyQuestionIntentFast(question, hasServices).intent;
675
+ return classifyQuestionIntentFast(question, hasServices, entityNames).intent;
573
676
  }
574
677
 
575
678
  export async function extractParameterSchemas(
@@ -629,6 +732,7 @@ export interface LegendAIChatProps {
629
732
  * returned by the orchestrator. Required for the execute-after-generate flow.
630
733
  */
631
734
  pureExecutionContext?: QueryExplicitExecutionContextInfo;
735
+ modelContext?: LegendAIModelContext;
632
736
  availableScopes?: LegendAIScopeItem[];
633
737
  /** Called when the user clicks the close button in the chat header. */
634
738
  onClose?: () => void;
@@ -641,4 +745,6 @@ export interface LegendAIChatProps {
641
745
  onMessageFeedback?: (
642
746
  feedback: LegendAIMessageFeedback,
643
747
  ) => Promise<void> | void;
748
+ onRequestAccess?: (accessPointGroupTitle: string) => void;
749
+ contextBannerMessage?: string;
644
750
  }
@@ -28,6 +28,7 @@ import type {
28
28
  LegendAIConversationTurn,
29
29
  LegendAIProductMetadata,
30
30
  LegendAIQuestionIntent,
31
+ LegendAIModelContext,
31
32
  } from './LegendAITypes.js';
32
33
 
33
34
  export class LegendAISqlExtractionResult {
@@ -63,6 +64,30 @@ export interface LegendAISemanticSearchResolutionDetails {
63
64
  data_product_coordinates: LegendAIOrchestratorDataProductCoordinates;
64
65
  root_entity: string;
65
66
  related_entities: string[];
67
+ enriched_business_context?: LegendAIEnrichedBusinessContext;
68
+ }
69
+
70
+ export interface LegendAIEnrichedBusinessContext {
71
+ naturalLanguageQuery?: string;
72
+ businessContextMatch?: LegendAIBusinessContextMatch;
73
+ }
74
+
75
+ export interface LegendAIBusinessContextMatch {
76
+ properties?: LegendAIBusinessContextProperty[];
77
+ additionalNlModelContext?: LegendAIAdditionalNlModelContext[];
78
+ }
79
+
80
+ export interface LegendAIBusinessContextProperty {
81
+ propertyName: string;
82
+ matchType?: string[];
83
+ propertyValues?: string[];
84
+ probablePropertyValues?: string[];
85
+ }
86
+
87
+ export interface LegendAIAdditionalNlModelContext {
88
+ id: string;
89
+ description: string;
90
+ category: string;
66
91
  }
67
92
 
68
93
  export class LegendAIOrchestratorRequest {
@@ -161,6 +186,8 @@ export abstract class LegendAI_LegendApplicationPlugin_Extension extends LegendA
161
186
  question: string,
162
187
  metadata: LegendAIProductMetadata,
163
188
  history?: LegendAIConversationTurn[],
189
+ services?: TDSServiceSchema[],
190
+ modelContextEnrichment?: string,
164
191
  ): string;
165
192
 
166
193
  /**
@@ -172,6 +199,7 @@ export abstract class LegendAI_LegendApplicationPlugin_Extension extends LegendA
172
199
  coordinates: string,
173
200
  history?: LegendAIConversationTurn[],
174
201
  metadata?: LegendAIProductMetadata,
202
+ modelContextEnrichment?: string,
175
203
  ): string;
176
204
 
177
205
  /**
@@ -194,6 +222,7 @@ export abstract class LegendAI_LegendApplicationPlugin_Extension extends LegendA
194
222
  question: string,
195
223
  accessPoints: TDSServiceSchema[],
196
224
  history?: LegendAIConversationTurn[],
225
+ modelContextEnrichment?: string,
197
226
  ): string;
198
227
 
199
228
  /**
@@ -205,8 +234,40 @@ export abstract class LegendAI_LegendApplicationPlugin_Extension extends LegendA
205
234
  question: string,
206
235
  accessPoints: TDSServiceSchema[],
207
236
  history?: LegendAIConversationTurn[],
237
+ modelContextEnrichment?: string,
208
238
  ): string;
209
239
 
240
+ buildAccessPointErrorCorrectionPrompt(
241
+ failedSql: string,
242
+ question: string,
243
+ executionError: string,
244
+ accessPoints: TDSServiceSchema[],
245
+ history?: LegendAIConversationTurn[],
246
+ modelContextEnrichment?: string,
247
+ ): string {
248
+ return this.buildAccessPointJudgePrompt(
249
+ failedSql,
250
+ `${question}\n\nEXECUTION ERROR — the previous SQL failed. Fix it using the SAME access points:\n${executionError}`,
251
+ accessPoints,
252
+ history,
253
+ modelContextEnrichment,
254
+ );
255
+ }
256
+
257
+ normalizeQuestion(
258
+ question: string,
259
+ _config: LegendAIConfig,
260
+ ): Promise<string> {
261
+ return Promise.resolve(question);
262
+ }
263
+
264
+ preWarmSchemaAnalysis(
265
+ _services: TDSServiceSchema[],
266
+ _config: LegendAIConfig,
267
+ ): void {
268
+ /* no-op by default — plugins override */
269
+ }
270
+
210
271
  /**
211
272
  * Send a prompt to the LLM service and return the raw response text.
212
273
  * The plugin manages conversation lifecycle internally — callers
@@ -252,6 +313,7 @@ export abstract class LegendAI_LegendApplicationPlugin_Extension extends LegendA
252
313
  dataProductCoordinates: LegendAIOrchestratorDataProductCoordinates,
253
314
  config: LegendAIConfig,
254
315
  pureExecutionContext?: QueryExplicitExecutionContextInfo,
316
+ modelContext?: LegendAIModelContext,
255
317
  ): Promise<LegendAIResolvedEntities>;
256
318
 
257
319
  /**
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { guaranteeNonNullable } from '@finos/legend-shared';
17
18
  import { createMock } from '@finos/legend-shared/test';
18
19
  import type {
19
20
  MessageSetter,
@@ -72,6 +73,7 @@ export const TEST__makeAssistantMessage = (
72
73
  isExecuting: false,
73
74
  suggestedQueries: [],
74
75
  fallbackAction: null,
76
+ queriedAccessPointGroups: [],
75
77
  ...overrides,
76
78
  });
77
79
  export const TEST__seedAssistant = (setter: MessageSetter): void => {
@@ -80,6 +82,27 @@ export const TEST__seedAssistant = (setter: MessageSetter): void => {
80
82
  TEST__makeAssistantMessage(),
81
83
  ]);
82
84
  };
85
+
86
+ /**
87
+ * Retrieve a message by index and assert it is an assistant message,
88
+ * narrowing the type via the discriminated `role` field without a cast.
89
+ */
90
+ export const TEST__getAssistantMessage = (
91
+ messages: LegendAIMessage[],
92
+ index: number,
93
+ ): LegendAIAssistantMessage => {
94
+ const msg = guaranteeNonNullable(
95
+ messages[index],
96
+ `Expected message at index ${index}`,
97
+ );
98
+ if (msg.role !== LegendAIMessageRole.ASSISTANT) {
99
+ throw new Error(
100
+ `Expected assistant message at index ${index}, got role '${msg.role}'`,
101
+ );
102
+ }
103
+ return msg;
104
+ };
105
+
83
106
  export const TEST_DATA__legendAIConfig: LegendAIConfig = {
84
107
  enabled: true,
85
108
  llmServiceUrl: 'http://localhost/llm',
@@ -140,13 +163,18 @@ export const TEST__createMockLegendAIPlugin = (
140
163
  _q: string,
141
164
  _s: TDSServiceSchema[],
142
165
  _h?: LegendAIConversationTurn[],
166
+ _m?: string,
143
167
  ) => 'ap generator prompt',
144
168
  buildAccessPointJudgePrompt: (
145
169
  _sql: string,
146
170
  _q: string,
147
171
  _s: TDSServiceSchema[],
148
172
  _h?: LegendAIConversationTurn[],
173
+ _m?: string,
149
174
  ) => 'ap judge prompt',
175
+ preWarmSchemaAnalysis: () => {
176
+ /* no-op in test */
177
+ },
150
178
  callLLM: createMock(),
151
179
  executeSql: createMock(),
152
180
  extractSqlFromResponse: (_a: string): LegendAISqlExtractionResult => ({