@finos/legend-lego 2.0.211 → 2.0.213

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 (74) 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 +12 -12
  4. package/lib/legend-ai/LegendAIDocEnrichment.d.ts.map +1 -1
  5. package/lib/legend-ai/LegendAIDocEnrichment.js +317 -86
  6. package/lib/legend-ai/LegendAIDocEnrichment.js.map +1 -1
  7. package/lib/legend-ai/LegendAIHostIntegration.d.ts +31 -0
  8. package/lib/legend-ai/LegendAIHostIntegration.d.ts.map +1 -0
  9. package/lib/legend-ai/LegendAIHostIntegration.js +50 -0
  10. package/lib/legend-ai/LegendAIHostIntegration.js.map +1 -0
  11. package/lib/legend-ai/LegendAIServiceRetrieval.d.ts.map +1 -1
  12. package/lib/legend-ai/LegendAIServiceRetrieval.js +34 -35
  13. package/lib/legend-ai/LegendAIServiceRetrieval.js.map +1 -1
  14. package/lib/legend-ai/LegendAITypes.d.ts +85 -7
  15. package/lib/legend-ai/LegendAITypes.d.ts.map +1 -1
  16. package/lib/legend-ai/LegendAITypes.js +99 -1
  17. package/lib/legend-ai/LegendAITypes.js.map +1 -1
  18. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts +36 -3
  19. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.d.ts.map +1 -1
  20. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js +29 -0
  21. package/lib/legend-ai/LegendAI_LegendApplicationPlugin_Extension.js.map +1 -1
  22. package/lib/legend-ai/__test-utils__/LegendAITestUtils.d.ts.map +1 -1
  23. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js +7 -0
  24. package/lib/legend-ai/__test-utils__/LegendAITestUtils.js.map +1 -1
  25. package/lib/legend-ai/components/LegendAICharts.d.ts.map +1 -1
  26. package/lib/legend-ai/components/LegendAICharts.js +6 -10
  27. package/lib/legend-ai/components/LegendAICharts.js.map +1 -1
  28. package/lib/legend-ai/components/LegendAIChat.d.ts.map +1 -1
  29. package/lib/legend-ai/components/LegendAIChat.js +263 -41
  30. package/lib/legend-ai/components/LegendAIChat.js.map +1 -1
  31. package/lib/legend-ai/components/LegendAIChatInput.d.ts +1 -1
  32. package/lib/legend-ai/components/LegendAIChatInput.d.ts.map +1 -1
  33. package/lib/legend-ai/components/LegendAIChatInput.js +2 -1
  34. package/lib/legend-ai/components/LegendAIChatInput.js.map +1 -1
  35. package/lib/legend-ai/index.d.ts +5 -2
  36. package/lib/legend-ai/index.d.ts.map +1 -1
  37. package/lib/legend-ai/index.js +5 -2
  38. package/lib/legend-ai/index.js.map +1 -1
  39. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts +9 -3
  40. package/lib/legend-ai/stores/LegendAIChatProcessors.d.ts.map +1 -1
  41. package/lib/legend-ai/stores/LegendAIChatProcessors.js +794 -220
  42. package/lib/legend-ai/stores/LegendAIChatProcessors.js.map +1 -1
  43. package/lib/legend-ai/stores/LegendAIChatState.d.ts.map +1 -1
  44. package/lib/legend-ai/stores/LegendAIChatState.js +47 -21
  45. package/lib/legend-ai/stores/LegendAIChatState.js.map +1 -1
  46. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts +29 -0
  47. package/lib/legend-ai/stores/LegendAIJoinAnalysis.d.ts.map +1 -0
  48. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js +124 -0
  49. package/lib/legend-ai/stores/LegendAIJoinAnalysis.js.map +1 -0
  50. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts +25 -0
  51. package/lib/legend-ai/stores/LegendAISqlHelpers.d.ts.map +1 -0
  52. package/lib/legend-ai/stores/LegendAISqlHelpers.js +64 -0
  53. package/lib/legend-ai/stores/LegendAISqlHelpers.js.map +1 -0
  54. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts +22 -0
  55. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.d.ts.map +1 -0
  56. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js +411 -0
  57. package/lib/legend-ai/stores/LegendAISqlJoinSanitizers.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/legend-ai/LegendAIDocEnrichment.ts +435 -97
  60. package/src/legend-ai/LegendAIHostIntegration.tsx +80 -0
  61. package/src/legend-ai/LegendAIServiceRetrieval.ts +50 -35
  62. package/src/legend-ai/LegendAITypes.ts +169 -5
  63. package/src/legend-ai/LegendAI_LegendApplicationPlugin_Extension.ts +73 -1
  64. package/src/legend-ai/__test-utils__/LegendAITestUtils.ts +10 -0
  65. package/src/legend-ai/components/LegendAICharts.tsx +30 -33
  66. package/src/legend-ai/components/LegendAIChat.tsx +555 -91
  67. package/src/legend-ai/components/LegendAIChatInput.tsx +6 -2
  68. package/src/legend-ai/index.ts +22 -3
  69. package/src/legend-ai/stores/LegendAIChatProcessors.ts +1286 -322
  70. package/src/legend-ai/stores/LegendAIChatState.ts +73 -28
  71. package/src/legend-ai/stores/LegendAIJoinAnalysis.ts +165 -0
  72. package/src/legend-ai/stores/LegendAISqlHelpers.ts +82 -0
  73. package/src/legend-ai/stores/LegendAISqlJoinSanitizers.ts +515 -0
  74. package/tsconfig.json +4 -0
@@ -19,6 +19,7 @@ import {
19
19
  extractElementNameFromPath,
20
20
  PRIMITIVE_TYPE,
21
21
  } from '@finos/legend-graph';
22
+ import { isNonNullable } from '@finos/legend-shared';
22
23
  import {
23
24
  ClassDocumentationEntry,
24
25
  AssociationDocumentationEntry,
@@ -40,7 +41,9 @@ import type {
40
41
  LegendAIEnrichedBusinessContext,
41
42
  LegendAIBusinessContextProperty,
42
43
  LegendAIAdditionalNlModelContext,
44
+ LegendAIResolvedEntities,
43
45
  } from './LegendAI_LegendApplicationPlugin_Extension.js';
46
+ import { sharedColumnNames } from './stores/LegendAISqlHelpers.js';
44
47
 
45
48
  /**
46
49
  * Builds a lookup map from lowercase property name to its documentation entry.
@@ -167,16 +170,6 @@ function buildAssociationAdjacency(
167
170
  return adjacency;
168
171
  }
169
172
 
170
- function findSharedColumns(
171
- svcA: TDSServiceSchema,
172
- svcB: TDSServiceSchema,
173
- ): string[] {
174
- const colsA = new Set(svcA.columns.map((c) => c.name.toLowerCase()));
175
- return svcB.columns
176
- .filter((c) => colsA.has(c.name.toLowerCase()))
177
- .map((c) => c.name);
178
- }
179
-
180
173
  function findViaConnectionFromClass(
181
174
  clsA: string,
182
175
  classNamesB: string[],
@@ -218,7 +211,7 @@ function findViaRelationship(
218
211
  return {
219
212
  leftService: svcA.title,
220
213
  rightService: svcB.title,
221
- joinColumns: findSharedColumns(svcA, svcB),
214
+ joinColumns: sharedColumnNames(svcB, svcA),
222
215
  viaEntity: via.viaEntity,
223
216
  leftCardinality: via.leftMult,
224
217
  rightCardinality: via.rightMult,
@@ -246,7 +239,7 @@ function findDirectRelationship(
246
239
  return {
247
240
  leftService: svcA.title,
248
241
  rightService: svcB.title,
249
- joinColumns: findSharedColumns(svcA, svcB),
242
+ joinColumns: sharedColumnNames(svcB, svcA),
250
243
  };
251
244
  }
252
245
  }
@@ -549,42 +542,6 @@ export function extractLambdaPreFilters(
549
542
  return results;
550
543
  }
551
544
 
552
- /**
553
- * Formats pre-filter constraints into a human-readable summary for LLM context.
554
- * This generates a concise description of what's hardcoded in the service lambda
555
- * so the AI avoids generating contradictory WHERE clauses.
556
- */
557
- export function formatPreFiltersForContext(
558
- preFilters: TDSServicePreFilter[],
559
- ): string {
560
- if (preFilters.length === 0) {
561
- return '';
562
- }
563
- const parts: string[] = [];
564
- for (const pf of preFilters) {
565
- const shortProp = pf.property.includes('.')
566
- ? (pf.property.split('.').pop() ?? pf.property)
567
- : pf.property;
568
- switch (pf.operator) {
569
- case 'equal':
570
- parts.push(`${shortProp} = '${String(pf.value)}'`);
571
- break;
572
- case 'isEmpty':
573
- parts.push(`${shortProp} IS NULL (always)`);
574
- break;
575
- case 'isNotEmpty':
576
- parts.push(`${shortProp} IS NOT NULL (always)`);
577
- break;
578
- case 'isNotNull':
579
- parts.push(`${shortProp} IS NOT NULL (post-filter)`);
580
- break;
581
- default:
582
- break;
583
- }
584
- }
585
- return `Pre-applied filters: ${parts.join('; ')}`;
586
- }
587
-
588
545
  // ────────────────────────────────────────────────────────────────────────────
589
546
  // Model context extraction from DataSpace elementDocs
590
547
  // ────────────────────────────────────────────────────────────────────────────
@@ -597,6 +554,8 @@ export function formatPreFiltersForContext(
597
554
  *
598
555
  * Only applicable to DataSpaces (not DataProducts, which lack elementDocs).
599
556
  */
557
+ const UNKNOWN_TYPE = 'Unknown';
558
+
600
559
  function collectClassEntities(
601
560
  classMap: Map<string, ClassDocumentationEntry>,
602
561
  ): LegendAIModelEntity[] {
@@ -605,7 +564,7 @@ function collectClassEntities(
605
564
  const upperBound = prop.multiplicity?.upperBound;
606
565
  return {
607
566
  name: prop.name,
608
- type: prop.type ?? 'Unknown',
567
+ type: prop.type ?? UNKNOWN_TYPE,
609
568
  isCollection: upperBound === undefined || upperBound > 1,
610
569
  isOptional: (prop.multiplicity?.lowerBound ?? 0) === 0,
611
570
  };
@@ -939,6 +898,43 @@ function buildCrossClassNlHints(
939
898
  ];
940
899
  }
941
900
 
901
+ const MAX_VALID_VALUES_PER_PROPERTY = 30;
902
+
903
+ function buildValidValueNlHints(
904
+ rootEntity: string,
905
+ relatedEntities: string[],
906
+ modelContext: LegendAIModelContext,
907
+ entityMap: Map<string, LegendAIModelEntity>,
908
+ ): LegendAIAdditionalNlModelContext[] {
909
+ const valuesByEnum = new Map<string, string[]>();
910
+ for (const enumeration of modelContext.enumerations ?? []) {
911
+ valuesByEnum.set(enumeration.path, enumeration.values);
912
+ valuesByEnum.set(enumeration.name, enumeration.values);
913
+ }
914
+ if (valuesByEnum.size === 0) {
915
+ return [];
916
+ }
917
+ const entries: LegendAIAdditionalNlModelContext[] = [];
918
+ for (const path of [rootEntity, ...relatedEntities]) {
919
+ const entity = entityMap.get(path);
920
+ if (!entity) {
921
+ continue;
922
+ }
923
+ for (const prop of entity.properties) {
924
+ const values = valuesByEnum.get(prop.type);
925
+ if (values && values.length > 0) {
926
+ const shown = values.slice(0, MAX_VALID_VALUES_PER_PROPERTY).join(', ');
927
+ entries.push({
928
+ id: `valid_values:${entity.name}.${prop.name}`,
929
+ description: `Valid values for '${prop.name}' on ${entity.name}: ${shown}. Filter using these exact values (match case-insensitively).`,
930
+ category: 'valid_values',
931
+ });
932
+ }
933
+ }
934
+ }
935
+ return entries;
936
+ }
937
+
942
938
  function buildNlContextEntries(
943
939
  question: string,
944
940
  rootEntity: string,
@@ -950,6 +946,14 @@ function buildNlContextEntries(
950
946
  const entries: LegendAIAdditionalNlModelContext[] = [
951
947
  ...buildRootEntityNlHints(rootEntity, rootEntityObj, modelContext),
952
948
  ];
949
+ if (rootEntityObj) {
950
+ entries.unshift({
951
+ id: 'filter_guidance',
952
+ description:
953
+ 'Filter guidance: match text/descriptive concepts on descriptive columns (e.g. description) using case-insensitive contains, not exact equals — and do NOT invent identifier/mnemonic codes (e.g. name, haverId, ticker) unless you were given their actual values. When several descriptive terms apply combine them with OR (never AND two contains on the same column). For country/geography prefer a coded column such as isoCode when one exists.',
954
+ category: 'filter_guidance',
955
+ });
956
+ }
953
957
  if (modelContext.dataspaceDescription) {
954
958
  const shortDesc = modelContext.dataspaceDescription
955
959
  .split('\n')
@@ -978,6 +982,12 @@ function buildNlContextEntries(
978
982
  entries.push(
979
983
  ...buildAssociationNlHints(rootEntity, relatedEntities, modelContext),
980
984
  ...buildExecutableNlHints(rootEntity, rootEntityObj, modelContext),
985
+ ...buildValidValueNlHints(
986
+ rootEntity,
987
+ relatedEntities,
988
+ modelContext,
989
+ entityMap,
990
+ ),
981
991
  );
982
992
  if (rootEntityObj) {
983
993
  entries.push(
@@ -1158,19 +1168,13 @@ export function buildSemanticPropertyIndex(
1158
1168
  const index = new Map<string, Set<string>>();
1159
1169
  for (const entity of modelContext.entities) {
1160
1170
  for (const prop of entity.properties) {
1161
- const nameTokens = prop.name
1162
- .replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/g, '$<lower> $<upper>')
1163
- .toLowerCase()
1164
- .split(/[\s_]+/)
1165
- .filter((t) => t.length > 2);
1171
+ const nameTokens = splitIdentifierTokens(prop.name).filter(
1172
+ (token) => token.length >= DEFAULT_MIN_TOKEN_LENGTH,
1173
+ );
1166
1174
  addTokensToIndex(index, nameTokens, entity.path);
1167
1175
  }
1168
1176
  if (entity.description) {
1169
- const descTokens = entity.description
1170
- .toLowerCase()
1171
- .split(/[\s,.;:!?'"()\-/]+/)
1172
- .filter((t) => t.length > 2);
1173
- addTokensToIndex(index, descTokens, entity.path);
1177
+ addTokensToIndex(index, tokenizeText(entity.description), entity.path);
1174
1178
  }
1175
1179
  }
1176
1180
  return index;
@@ -1180,6 +1184,8 @@ export function buildSemanticPropertyIndex(
1180
1184
  // Deterministic entity resolution (no LLM required)
1181
1185
  // ────────────────────────────────────────────────────────────────────────────
1182
1186
 
1187
+ const MAX_RESOLVED_RELATED_ENTITIES = 5;
1188
+
1183
1189
  /**
1184
1190
  * Resolves root and related entities using keyword matching against the model
1185
1191
  * context. This works without any LLM call and is used as the final fallback
@@ -1199,7 +1205,7 @@ export function buildSemanticPropertyIndex(
1199
1205
  function pickFallbackEntity(
1200
1206
  queryableEntities: LegendAIModelEntity[],
1201
1207
  entities: LegendAIModelEntity[],
1202
- ): { rootEntity: string; relatedEntities: string[] } | undefined {
1208
+ ): LegendAIResolvedEntities | undefined {
1203
1209
  const preferred =
1204
1210
  queryableEntities[0] ?? entities.find((e) => e.isRootMapped) ?? entities[0];
1205
1211
  return preferred
@@ -1217,12 +1223,36 @@ function buildExecutableByRootIndex(
1217
1223
  words = [];
1218
1224
  index.set(exec.rootEntityPath, words);
1219
1225
  }
1220
- words.push(
1221
- ...`${exec.title} ${exec.description ?? ''}`
1222
- .toLowerCase()
1223
- .split(/[\s,.;:!?'"()\-/]+/)
1224
- .filter((t) => t.length > 2),
1226
+ words.push(...tokenizeText(`${exec.title} ${exec.description ?? ''}`));
1227
+ for (const param of exec.requiredParameters ?? []) {
1228
+ words.push(...tokenizeText(param.name));
1229
+ }
1230
+ }
1231
+ return index;
1232
+ }
1233
+
1234
+ function buildEnumValueIndex(
1235
+ modelContext: LegendAIModelContext,
1236
+ ): Map<string, Set<string>> {
1237
+ const valueTokensByEnum = new Map<string, string[]>();
1238
+ for (const enumeration of modelContext.enumerations ?? []) {
1239
+ const valueTokens = enumeration.values.flatMap((value) =>
1240
+ tokenizeText(value),
1225
1241
  );
1242
+ valueTokensByEnum.set(enumeration.path, valueTokens);
1243
+ valueTokensByEnum.set(enumeration.name, valueTokens);
1244
+ }
1245
+ const index = new Map<string, Set<string>>();
1246
+ if (valueTokensByEnum.size === 0) {
1247
+ return index;
1248
+ }
1249
+ for (const entity of modelContext.entities) {
1250
+ for (const prop of entity.properties) {
1251
+ const valueTokens = valueTokensByEnum.get(prop.type);
1252
+ if (valueTokens) {
1253
+ addTokensToIndex(index, valueTokens, entity.path);
1254
+ }
1255
+ }
1226
1256
  }
1227
1257
  return index;
1228
1258
  }
@@ -1249,6 +1279,7 @@ function scoreEntity(
1249
1279
  tokens: string[],
1250
1280
  semanticIndex: Map<string, Set<string>>,
1251
1281
  executableByRoot: Map<string, string[]>,
1282
+ enumValueIndex: Map<string, Set<string>>,
1252
1283
  ): number {
1253
1284
  const nameLower = entity.name.toLowerCase();
1254
1285
  let score =
@@ -1271,13 +1302,37 @@ function scoreEntity(
1271
1302
  .length * 4;
1272
1303
  }
1273
1304
  score += tokens.filter((t) => semanticIndex.get(t)?.has(entity.path)).length;
1305
+ score +=
1306
+ tokens.filter((t) => enumValueIndex.get(t)?.has(entity.path)).length * 2;
1274
1307
  return score;
1275
1308
  }
1276
1309
 
1310
+ export function rankEntities(
1311
+ question: string,
1312
+ modelContext: LegendAIModelContext,
1313
+ ): { entity: LegendAIModelEntity; score: number }[] {
1314
+ const executableByRoot = buildExecutableByRootIndex(modelContext);
1315
+ const semanticIndex = buildSemanticPropertyIndex(modelContext);
1316
+ const enumValueIndex = buildEnumValueIndex(modelContext);
1317
+ const tokens = tokenizeText(question);
1318
+ return modelContext.entities
1319
+ .map((entity) => ({
1320
+ entity,
1321
+ score: scoreEntity(
1322
+ entity,
1323
+ tokens,
1324
+ semanticIndex,
1325
+ executableByRoot,
1326
+ enumValueIndex,
1327
+ ),
1328
+ }))
1329
+ .sort((a, b) => b.score - a.score);
1330
+ }
1331
+
1277
1332
  export function resolveEntitiesDeterministic(
1278
1333
  question: string,
1279
1334
  modelContext: LegendAIModelContext,
1280
- ): { rootEntity: string; relatedEntities: string[] } | undefined {
1335
+ ): LegendAIResolvedEntities | undefined {
1281
1336
  if (modelContext.entities.length === 0) {
1282
1337
  return undefined;
1283
1338
  }
@@ -1289,24 +1344,12 @@ export function resolveEntitiesDeterministic(
1289
1344
  }
1290
1345
 
1291
1346
  const queryableEntities = modelContext.entities.filter((e) => e.isQueryable);
1292
- const executableByRoot = buildExecutableByRootIndex(modelContext);
1293
- const semanticIndex = buildSemanticPropertyIndex(modelContext);
1294
-
1295
- const tokens = question
1296
- .toLowerCase()
1297
- .split(/[\s,.;:!?'"()\-/]+/)
1298
- .filter((t) => t.length > 2);
1299
1347
 
1300
- if (tokens.length === 0) {
1348
+ if (tokenizeText(question).length === 0) {
1301
1349
  return pickFallbackEntity(queryableEntities, modelContext.entities);
1302
1350
  }
1303
1351
 
1304
- const scored = modelContext.entities
1305
- .map((entity) => ({
1306
- entity,
1307
- score: scoreEntity(entity, tokens, semanticIndex, executableByRoot),
1308
- }))
1309
- .sort((a, b) => b.score - a.score);
1352
+ const scored = rankEntities(question, modelContext);
1310
1353
 
1311
1354
  const rootCandidates =
1312
1355
  queryableEntities.length > 0
@@ -1328,17 +1371,143 @@ export function resolveEntitiesDeterministic(
1328
1371
  }
1329
1372
  }
1330
1373
  for (const s of scored.slice(1)) {
1331
- if (s.score > 0 && relatedSet.size < 5) {
1374
+ if (s.score > 0 && relatedSet.size < MAX_RESOLVED_RELATED_ENTITIES) {
1332
1375
  relatedSet.add(s.entity.path);
1333
1376
  }
1334
1377
  }
1335
1378
 
1336
1379
  return {
1337
1380
  rootEntity: rootPath,
1338
- relatedEntities: Array.from(relatedSet).slice(0, 5),
1381
+ relatedEntities: Array.from(relatedSet).slice(
1382
+ 0,
1383
+ MAX_RESOLVED_RELATED_ENTITIES,
1384
+ ),
1339
1385
  };
1340
1386
  }
1341
1387
 
1388
+ const MAX_APPROACH_COLUMNS = 6;
1389
+ const MAX_APPROACH_DESCRIPTION_LENGTH = 200;
1390
+ const MAX_DESCRIPTION_PREVIEW_LENGTH = 300;
1391
+
1392
+ export function buildDataQueryApproachText(
1393
+ question: string,
1394
+ modelContext: LegendAIModelContext,
1395
+ rootEntityPath?: string,
1396
+ ): string | undefined {
1397
+ const rootPath =
1398
+ rootEntityPath ??
1399
+ resolveEntitiesDeterministic(question, modelContext)?.rootEntity;
1400
+ if (!rootPath) {
1401
+ return undefined;
1402
+ }
1403
+ const root = modelContext.entities.find((entity) => entity.path === rootPath);
1404
+ if (!root) {
1405
+ return undefined;
1406
+ }
1407
+
1408
+ const service = modelContext.executables?.find(
1409
+ (exec) => exec.rootEntityPath === root.path,
1410
+ )?.title;
1411
+ const columns = root.properties
1412
+ .filter((prop) => !prop.type.includes('::'))
1413
+ .map((prop) => prop.name);
1414
+ const relatedPaths = new Set<string>();
1415
+ for (const assoc of modelContext.associations) {
1416
+ if (assoc.leftEntity === root.path) {
1417
+ relatedPaths.add(assoc.rightEntity);
1418
+ } else if (assoc.rightEntity === root.path) {
1419
+ relatedPaths.add(assoc.leftEntity);
1420
+ }
1421
+ }
1422
+ const related = Array.from(relatedPaths).map((path) =>
1423
+ extractElementNameFromPath(path),
1424
+ );
1425
+
1426
+ const sentences: string[] = [];
1427
+ const via = service ? ` via the **${service}** service` : '';
1428
+ const description = root.description
1429
+ ? ` — ${root.description.slice(0, MAX_APPROACH_DESCRIPTION_LENGTH).trim()}`
1430
+ : '';
1431
+ sentences.push(
1432
+ `To answer this, I'll query **${root.name}**${via}${description}.`,
1433
+ );
1434
+
1435
+ if (columns.length > 0) {
1436
+ const shown = columns
1437
+ .slice(0, MAX_APPROACH_COLUMNS)
1438
+ .map((name) => `\`${name}\``)
1439
+ .join(', ');
1440
+ const more =
1441
+ columns.length > MAX_APPROACH_COLUMNS
1442
+ ? ` (and ${columns.length - MAX_APPROACH_COLUMNS} more)`
1443
+ : '';
1444
+ sentences.push(`It exposes fields such as ${shown}${more}.`);
1445
+ }
1446
+
1447
+ if (related.length > 0) {
1448
+ const list = related.map((name) => `**${name}**`).join(', ');
1449
+ sentences.push(
1450
+ `Where the question needs more, results can be enriched by joining related ${
1451
+ related.length > 1 ? 'entities' : 'entity'
1452
+ } ${list} through the model's associations.`,
1453
+ );
1454
+ }
1455
+
1456
+ return sentences.join(' ');
1457
+ }
1458
+
1459
+ const EXACT_STRING_FILTER_PATTERN =
1460
+ /(?<lhs>\$\w+(?:\.\w+)+(?:->toOne\(\))?)\s*==\s*'(?<literal>[^']*)'/g;
1461
+
1462
+ export function relaxExactStringFilters(pureQuery: string): string {
1463
+ return pureQuery.replace(
1464
+ EXACT_STRING_FILTER_PATTERN,
1465
+ (_match, lhs: string, literal: string) =>
1466
+ `${lhs}->toLower()->contains('${literal.toLowerCase()}')`,
1467
+ );
1468
+ }
1469
+
1470
+ const STRING_COMPARISON_COLUMN_PATTERN =
1471
+ /\$\w+(?:\.\w+)*\.(?<col>\w+)(?:->toOne\(\))?\s*(?:==|!=|<=|>=|<|>)\s*'/g;
1472
+ const CONTAINS_COLUMN_PATTERN =
1473
+ /\$\w+(?:\.\w+)*\.(?<col>\w+)(?:->to\w+\(\))*->(?:contains|startsWith|endsWith)\(/g;
1474
+
1475
+ export function extractFilteredColumns(pureQuery: string): string[] {
1476
+ const columns = new Set<string>();
1477
+ for (const pattern of [
1478
+ STRING_COMPARISON_COLUMN_PATTERN,
1479
+ CONTAINS_COLUMN_PATTERN,
1480
+ ]) {
1481
+ for (const match of pureQuery.matchAll(pattern)) {
1482
+ const col = match.groups?.col;
1483
+ if (col) {
1484
+ columns.add(col);
1485
+ }
1486
+ }
1487
+ }
1488
+ return Array.from(columns);
1489
+ }
1490
+
1491
+ const MAX_PROBED_VALUES_PER_COLUMN = 30;
1492
+
1493
+ export function buildProbedValueHints(
1494
+ probed: Map<string, string[]>,
1495
+ ): LegendAIAdditionalNlModelContext[] {
1496
+ const entries: LegendAIAdditionalNlModelContext[] = [];
1497
+ for (const [column, values] of probed) {
1498
+ if (values.length === 0) {
1499
+ continue;
1500
+ }
1501
+ const shown = values.slice(0, MAX_PROBED_VALUES_PER_COLUMN).join(', ');
1502
+ entries.push({
1503
+ id: `probed_values:${column}`,
1504
+ description: `Actual values present in column '${column}': ${shown}. Filter using one of these exact values (case-insensitively).`,
1505
+ category: 'probed_values',
1506
+ });
1507
+ }
1508
+ return entries;
1509
+ }
1510
+
1342
1511
  // ────────────────────────────────────────────────────────────────────────────
1343
1512
  // Model context enrichment for SQL / metadata prompts
1344
1513
  // ────────────────────────────────────────────────────────────────────────────
@@ -1348,6 +1517,9 @@ const MAX_ENRICHMENT_PROPS_PER_ENTITY = 15;
1348
1517
  const MAX_ENRICHMENT_ENUMS = 10;
1349
1518
  const MAX_ENRICHMENT_ENUM_VALUES = 20;
1350
1519
  const MAX_ENRICHMENT_ASSOCIATIONS = 10;
1520
+ const MAX_ENRICHMENT_EXECUTABLES = 25;
1521
+ const MAX_ENRICHMENT_JOIN_SERVICES = 25;
1522
+ const MIN_SERVICE_ENTITY_COLUMN_OVERLAP = 2;
1351
1523
 
1352
1524
  /**
1353
1525
  * Maps each TDS service to the model entity whose properties best match
@@ -1363,7 +1535,6 @@ function inferServiceEntityMapping(
1363
1535
  modelContext: LegendAIModelContext,
1364
1536
  ): Map<string, string> {
1365
1537
  const result = new Map<string, string>();
1366
- const MIN_OVERLAP = 2; // require at least 2 matching columns
1367
1538
 
1368
1539
  const entityPropSets = modelContext.entities.map((entity) => ({
1369
1540
  path: entity.path,
@@ -1383,7 +1554,7 @@ function inferServiceEntityMapping(
1383
1554
  }
1384
1555
  }
1385
1556
 
1386
- if (bestScore >= MIN_OVERLAP && bestEntity) {
1557
+ if (bestScore >= MIN_SERVICE_ENTITY_COLUMN_OVERLAP && bestEntity) {
1387
1558
  result.set(svc.title, bestEntity);
1388
1559
  }
1389
1560
  }
@@ -1431,7 +1602,7 @@ function buildEntitySection(
1431
1602
  const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
1432
1603
  lines.push(`\n### ${entity.name}${tagStr}`);
1433
1604
  if (entity.description) {
1434
- lines.push(entity.description.slice(0, 300));
1605
+ lines.push(entity.description.slice(0, MAX_DESCRIPTION_PREVIEW_LENGTH));
1435
1606
  }
1436
1607
  const propLines = entity.properties
1437
1608
  .slice(0, MAX_ENRICHMENT_PROPS_PER_ENTITY)
@@ -1493,7 +1664,10 @@ function buildExecutableSection(
1493
1664
  return undefined;
1494
1665
  }
1495
1666
  const lines = ['## Available Executables'];
1496
- for (const exec of modelContext.executables) {
1667
+ for (const exec of modelContext.executables.slice(
1668
+ 0,
1669
+ MAX_ENRICHMENT_EXECUTABLES,
1670
+ )) {
1497
1671
  lines.push(
1498
1672
  `\n- "${exec.title}" → ${extractElementNameFromPath(exec.rootEntityPath)}`,
1499
1673
  );
@@ -1507,6 +1681,11 @@ function buildExecutableSection(
1507
1681
  lines.push(` Required parameters: ${paramList}`);
1508
1682
  }
1509
1683
  }
1684
+ if (modelContext.executables.length > MAX_ENRICHMENT_EXECUTABLES) {
1685
+ lines.push(
1686
+ `\n(${modelContext.executables.length - MAX_ENRICHMENT_EXECUTABLES} additional executables omitted)`,
1687
+ );
1688
+ }
1510
1689
  return lines.join('\n');
1511
1690
  }
1512
1691
 
@@ -1659,15 +1838,10 @@ function buildServicePairHint(
1659
1838
  }
1660
1839
  const leftName = extractElementNameFromPath(assoc.leftEntity);
1661
1840
  const rightName = extractElementNameFromPath(assoc.rightEntity);
1662
- const colsA = new Set(
1663
- services
1664
- .find((s) => s.title === svcA)
1665
- ?.columns.map((c) => c.name.toLowerCase()) ?? [],
1666
- );
1667
- const svcBCols = services.find((s) => s.title === svcB)?.columns ?? [];
1668
- const shared = svcBCols
1669
- .filter((c) => colsA.has(c.name.toLowerCase()))
1670
- .map((c) => c.name);
1841
+ const serviceA = services.find((s) => s.title === svcA);
1842
+ const serviceB = services.find((s) => s.title === svcB);
1843
+ const shared =
1844
+ serviceA && serviceB ? sharedColumnNames(serviceB, serviceA) : [];
1671
1845
  const joinKeyHint =
1672
1846
  shared.length > 0
1673
1847
  ? `JOIN ON shared columns: ${shared.join(', ')}`
@@ -1721,19 +1895,26 @@ function buildServiceJoinSection(
1721
1895
  if (serviceEntityMap.size === 0) {
1722
1896
  return undefined;
1723
1897
  }
1898
+ const allEntries = Array.from(serviceEntityMap);
1899
+ const cappedEntries = allEntries.slice(0, MAX_ENRICHMENT_JOIN_SERVICES);
1724
1900
  const joinLines: string[] = [
1725
1901
  '## Model-Aware Service JOIN Guide',
1726
1902
  'Each service below is mapped to the data model entity whose properties best match its columns.',
1727
1903
  'Use the entity relationships to determine correct JOIN keys and semantics.',
1728
1904
  '',
1729
1905
  ];
1730
- for (const [svcTitle, entityPath] of serviceEntityMap) {
1906
+ for (const [svcTitle, entityPath] of cappedEntries) {
1731
1907
  joinLines.push(
1732
1908
  `- **${svcTitle}** → entity ${extractElementNameFromPath(entityPath)}`,
1733
1909
  );
1734
1910
  }
1911
+ if (allEntries.length > MAX_ENRICHMENT_JOIN_SERVICES) {
1912
+ joinLines.push(
1913
+ `\n(${allEntries.length - MAX_ENRICHMENT_JOIN_SERVICES} additional services omitted)`,
1914
+ );
1915
+ }
1735
1916
  const pairHints = buildServicePairHints(
1736
- serviceEntityMap,
1917
+ new Map(cappedEntries),
1737
1918
  modelContext,
1738
1919
  services,
1739
1920
  );
@@ -1767,3 +1948,160 @@ The following describes the underlying data model — entities, properties, enum
1767
1948
 
1768
1949
  ${sections.join('\n\n')}`;
1769
1950
  }
1951
+
1952
+ const MAX_CATALOG_ENTITIES = 40;
1953
+ const MAX_CATALOG_PROPS_PER_ENTITY = 50;
1954
+ const MAX_CATALOG_ENUMS = 30;
1955
+ const MAX_CATALOG_ENUM_VALUES = 50;
1956
+ const MAX_CATALOG_ASSOCIATIONS = 40;
1957
+ const MAX_CATALOG_EXECUTABLES = 30;
1958
+
1959
+ /** Formats a property's multiplicity marker from its cardinality flags. */
1960
+ function formatCatalogMultiplicity(property: LegendAIModelProperty): string {
1961
+ if (property.isCollection) {
1962
+ return '[*]';
1963
+ }
1964
+ return property.isOptional ? '[0..1]' : '[1]';
1965
+ }
1966
+
1967
+ /** Builds the entities-and-columns section of the model catalog. */
1968
+ function buildCatalogEntitiesSection(
1969
+ modelContext: LegendAIModelContext,
1970
+ ): string | undefined {
1971
+ if (modelContext.entities.length === 0) {
1972
+ return undefined;
1973
+ }
1974
+ const scoreOf = (e: LegendAIModelEntity): number =>
1975
+ (e.isQueryable ? 10 : 0) + (e.isRootMapped ? 3 : 0);
1976
+ const sorted = [...modelContext.entities].sort(
1977
+ (a, b) => scoreOf(b) - scoreOf(a),
1978
+ );
1979
+ const lines: string[] = ['## Entities and Columns'];
1980
+ for (const entity of sorted.slice(0, MAX_CATALOG_ENTITIES)) {
1981
+ const tags: string[] = [];
1982
+ if (entity.isQueryable) {
1983
+ tags.push('queryable');
1984
+ }
1985
+ if (entity.isRootMapped) {
1986
+ tags.push('root-mapped');
1987
+ }
1988
+ const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
1989
+ lines.push(`\n### ${entity.name}${tagStr}`);
1990
+ if (entity.description) {
1991
+ lines.push(entity.description);
1992
+ }
1993
+ const propLines = entity.properties
1994
+ .slice(0, MAX_CATALOG_PROPS_PER_ENTITY)
1995
+ .map((p) => `- ${p.name}: ${p.type} ${formatCatalogMultiplicity(p)}`);
1996
+ if (propLines.length > 0) {
1997
+ lines.push('Columns:', ...propLines);
1998
+ }
1999
+ if (entity.properties.length > MAX_CATALOG_PROPS_PER_ENTITY) {
2000
+ lines.push(
2001
+ `- (${entity.properties.length - MAX_CATALOG_PROPS_PER_ENTITY} more columns)`,
2002
+ );
2003
+ }
2004
+ }
2005
+ if (modelContext.entities.length > MAX_CATALOG_ENTITIES) {
2006
+ lines.push(
2007
+ `\n(${modelContext.entities.length - MAX_CATALOG_ENTITIES} additional entities omitted — ask about a specific one for its columns)`,
2008
+ );
2009
+ }
2010
+ return lines.join('\n');
2011
+ }
2012
+
2013
+ /** Builds the relationships section of the model catalog. */
2014
+ function buildCatalogRelationshipsSection(
2015
+ modelContext: LegendAIModelContext,
2016
+ ): string | undefined {
2017
+ if (modelContext.associations.length === 0) {
2018
+ return undefined;
2019
+ }
2020
+ const lines: string[] = ['## Relationships'];
2021
+ for (const assoc of modelContext.associations.slice(
2022
+ 0,
2023
+ MAX_CATALOG_ASSOCIATIONS,
2024
+ )) {
2025
+ lines.push(
2026
+ `- ${assoc.leftEntity}.${assoc.leftProperty} <-> ${assoc.rightEntity}.${assoc.rightProperty}`,
2027
+ );
2028
+ }
2029
+ if (modelContext.associations.length > MAX_CATALOG_ASSOCIATIONS) {
2030
+ lines.push(
2031
+ `- (${modelContext.associations.length - MAX_CATALOG_ASSOCIATIONS} more relationships)`,
2032
+ );
2033
+ }
2034
+ return lines.join('\n');
2035
+ }
2036
+
2037
+ /** Builds the enumerations (valid values) section of the model catalog. */
2038
+ function buildCatalogEnumerationsSection(
2039
+ modelContext: LegendAIModelContext,
2040
+ ): string | undefined {
2041
+ const enumerations = modelContext.enumerations ?? [];
2042
+ if (enumerations.length === 0) {
2043
+ return undefined;
2044
+ }
2045
+ const lines: string[] = ['## Enumerations (valid values)'];
2046
+ for (const en of enumerations.slice(0, MAX_CATALOG_ENUMS)) {
2047
+ const shown = en.values.slice(0, MAX_CATALOG_ENUM_VALUES).join(', ');
2048
+ const more =
2049
+ en.values.length > MAX_CATALOG_ENUM_VALUES
2050
+ ? `, ... (${en.values.length} total)`
2051
+ : '';
2052
+ lines.push(`- ${en.name}: ${shown}${more}`);
2053
+ }
2054
+ return lines.join('\n');
2055
+ }
2056
+
2057
+ /** Builds the available-executables section of the model catalog. */
2058
+ function buildCatalogExecutablesSection(
2059
+ modelContext: LegendAIModelContext,
2060
+ ): string | undefined {
2061
+ const executables = modelContext.executables ?? [];
2062
+ if (executables.length === 0) {
2063
+ return undefined;
2064
+ }
2065
+ const lines: string[] = ['## How to query (available executables)'];
2066
+ for (const exec of executables.slice(0, MAX_CATALOG_EXECUTABLES)) {
2067
+ const root = exec.rootEntityPath
2068
+ ? ` → ${extractElementNameFromPath(exec.rootEntityPath)}`
2069
+ : '';
2070
+ lines.push(`\n- **${exec.title}**${root}`);
2071
+ if (exec.description) {
2072
+ lines.push(
2073
+ ` ${exec.description.slice(0, MAX_DESCRIPTION_PREVIEW_LENGTH)}`,
2074
+ );
2075
+ }
2076
+ if (exec.requiredParameters && exec.requiredParameters.length > 0) {
2077
+ const paramList = exec.requiredParameters
2078
+ .map((p) => `${p.name} (${p.type})`)
2079
+ .join(', ');
2080
+ lines.push(` Required parameters: ${paramList}`);
2081
+ }
2082
+ }
2083
+ if (executables.length > MAX_CATALOG_EXECUTABLES) {
2084
+ lines.push(`\n(${executables.length - MAX_CATALOG_EXECUTABLES} more)`);
2085
+ }
2086
+ return lines.join('\n');
2087
+ }
2088
+
2089
+ export function buildModelCatalogText(
2090
+ modelContext: LegendAIModelContext,
2091
+ ): string | undefined {
2092
+ const sections = [
2093
+ buildCatalogEntitiesSection(modelContext),
2094
+ buildCatalogRelationshipsSection(modelContext),
2095
+ buildCatalogEnumerationsSection(modelContext),
2096
+ buildCatalogExecutablesSection(modelContext),
2097
+ ].filter(isNonNullable);
2098
+
2099
+ if (sections.length === 0) {
2100
+ return undefined;
2101
+ }
2102
+
2103
+ return `# MODEL REFERENCE
2104
+ A reference for this data product's model. Use it to explain the entities and their columns, how entities relate, valid enumeration values, and how to query the data. Give a thorough, well-structured answer.
2105
+
2106
+ ${sections.join('\n\n')}`;
2107
+ }