@finos/legend-lego 2.0.201 → 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
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { extractElementNameFromPath, PRIMITIVE_TYPE, } from '@finos/legend-graph';
|
|
17
|
+
import { ClassDocumentationEntry, AssociationDocumentationEntry, EnumerationDocumentationEntry, PropertyDocumentationEntry, } from '../model-documentation/index.js';
|
|
17
18
|
/**
|
|
18
19
|
* Builds a lookup map from lowercase property name to its documentation entry.
|
|
19
20
|
* Used to enrich TDS columns with business descriptions and nullability
|
|
@@ -426,4 +427,892 @@ export function formatPreFiltersForContext(preFilters) {
|
|
|
426
427
|
}
|
|
427
428
|
return `Pre-applied filters: ${parts.join('; ')}`;
|
|
428
429
|
}
|
|
430
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
// Model context extraction from DataSpace elementDocs
|
|
432
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
433
|
+
/**
|
|
434
|
+
* Extracts a structured model context from DataSpace elementDocs.
|
|
435
|
+
* The resulting {@link LegendAIModelContext} provides all class entities,
|
|
436
|
+
* associations, and enumerations needed for local entity resolution in the
|
|
437
|
+
* orchestrator flow — eliminating the need for a marketplace search API call.
|
|
438
|
+
*
|
|
439
|
+
* Only applicable to DataSpaces (not DataProducts, which lack elementDocs).
|
|
440
|
+
*/
|
|
441
|
+
function collectClassEntities(classMap) {
|
|
442
|
+
return Array.from(classMap.values()).map((cls) => {
|
|
443
|
+
const properties = cls.properties.map((prop) => {
|
|
444
|
+
const upperBound = prop.multiplicity?.upperBound;
|
|
445
|
+
return {
|
|
446
|
+
name: prop.name,
|
|
447
|
+
type: prop.type ?? 'Unknown',
|
|
448
|
+
isCollection: upperBound === undefined || upperBound > 1,
|
|
449
|
+
isOptional: (prop.multiplicity?.lowerBound ?? 0) === 0,
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
const entity = {
|
|
453
|
+
path: cls.path,
|
|
454
|
+
name: cls.name,
|
|
455
|
+
properties,
|
|
456
|
+
};
|
|
457
|
+
if (cls.docs.length > 0) {
|
|
458
|
+
entity.description = cls.docs.join('; ');
|
|
459
|
+
}
|
|
460
|
+
return entity;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function collectAssociations(assocMap) {
|
|
464
|
+
const associations = [];
|
|
465
|
+
for (const assoc of assocMap.values()) {
|
|
466
|
+
if (assoc.properties.length !== 2) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
const [propA, propB] = assoc.properties;
|
|
470
|
+
if (propA?.type && propB?.type) {
|
|
471
|
+
associations.push({
|
|
472
|
+
name: assoc.name,
|
|
473
|
+
leftEntity: propA.type,
|
|
474
|
+
leftProperty: propA.name,
|
|
475
|
+
rightEntity: propB.type,
|
|
476
|
+
rightProperty: propB.name,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return associations;
|
|
481
|
+
}
|
|
482
|
+
function buildUniqueEntryMap(elementDocs, EntryClass) {
|
|
483
|
+
const map = new Map();
|
|
484
|
+
for (const entry of elementDocs) {
|
|
485
|
+
if (entry.elementEntry instanceof EntryClass &&
|
|
486
|
+
!map.has(entry.elementEntry.path)) {
|
|
487
|
+
map.set(entry.elementEntry.path, entry.elementEntry);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return map;
|
|
491
|
+
}
|
|
492
|
+
export function extractModelContext(elementDocs) {
|
|
493
|
+
const entities = collectClassEntities(buildUniqueEntryMap(elementDocs, ClassDocumentationEntry));
|
|
494
|
+
const associations = collectAssociations(buildUniqueEntryMap(elementDocs, AssociationDocumentationEntry));
|
|
495
|
+
const enumMap = buildUniqueEntryMap(elementDocs, EnumerationDocumentationEntry);
|
|
496
|
+
const enumerations = enumMap.size > 0
|
|
497
|
+
? Array.from(enumMap.values()).map((e) => ({
|
|
498
|
+
path: e.path,
|
|
499
|
+
name: e.name,
|
|
500
|
+
values: e.enumValues.map((v) => v.name),
|
|
501
|
+
}))
|
|
502
|
+
: undefined;
|
|
503
|
+
const result = { entities, associations };
|
|
504
|
+
if (enumerations) {
|
|
505
|
+
result.enumerations = enumerations;
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|
|
509
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
510
|
+
// Enriched business context for orchestrator payload
|
|
511
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
512
|
+
const MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES = 10;
|
|
513
|
+
function buildRootEntityProperties(rootEntityObj, modelContext) {
|
|
514
|
+
if (!rootEntityObj) {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
return rootEntityObj.properties.map((prop) => {
|
|
518
|
+
const entry = { propertyName: prop.name };
|
|
519
|
+
const enumMatch = modelContext.enumerations?.find((e) => e.path === prop.type);
|
|
520
|
+
if (enumMatch) {
|
|
521
|
+
entry.probablePropertyValues = enumMatch.values;
|
|
522
|
+
entry.matchType = ['enumeration'];
|
|
523
|
+
}
|
|
524
|
+
else if (isPrimitiveType(prop.type)) {
|
|
525
|
+
entry.matchType = [prop.type];
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
entry.matchType = ['entity'];
|
|
529
|
+
}
|
|
530
|
+
return entry;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
function buildRelatedEntityProperties(relatedEntities, entityMap) {
|
|
534
|
+
const properties = [];
|
|
535
|
+
let relatedPropCount = 0;
|
|
536
|
+
for (const relPath of relatedEntities) {
|
|
537
|
+
if (relatedPropCount >= MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES) {
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
const relEntity = entityMap.get(relPath);
|
|
541
|
+
if (!relEntity) {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
for (const prop of relEntity.properties) {
|
|
545
|
+
if (relatedPropCount >= MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES) {
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
if (isPrimitiveType(prop.type)) {
|
|
549
|
+
properties.push({
|
|
550
|
+
propertyName: `${relEntity.name}.${prop.name}`,
|
|
551
|
+
matchType: [prop.type],
|
|
552
|
+
});
|
|
553
|
+
relatedPropCount++;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return properties;
|
|
558
|
+
}
|
|
559
|
+
function buildRootEntityNlHints(rootEntity, rootEntityObj, modelContext) {
|
|
560
|
+
if (!rootEntityObj) {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
const entries = [];
|
|
564
|
+
if (rootEntityObj.description) {
|
|
565
|
+
entries.push({
|
|
566
|
+
id: rootEntity,
|
|
567
|
+
description: rootEntityObj.description,
|
|
568
|
+
category: 'root_entity',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (rootEntityObj.isRootMapped) {
|
|
572
|
+
entries.push({
|
|
573
|
+
id: rootEntity,
|
|
574
|
+
description: `${rootEntityObj.name} is a directly mapped root entity — it can be queried directly without navigating from another entity`,
|
|
575
|
+
category: 'mapping_hint',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (rootEntityObj.isQueryable) {
|
|
579
|
+
const matchingExec = modelContext.executables?.find((ex) => ex.rootEntityPath === rootEntity);
|
|
580
|
+
const execHint = matchingExec ? ` (service: "${matchingExec.title}")` : '';
|
|
581
|
+
entries.push({
|
|
582
|
+
id: rootEntity,
|
|
583
|
+
description: `${rootEntityObj.name} is queryable via a service executable${execHint} — this entity can be queried with .all()`,
|
|
584
|
+
category: 'queryable_hint',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return entries;
|
|
588
|
+
}
|
|
589
|
+
function buildAssociationNlHints(rootEntity, relatedEntities, modelContext) {
|
|
590
|
+
const involvedPaths = new Set([rootEntity, ...relatedEntities]);
|
|
591
|
+
return modelContext.associations
|
|
592
|
+
.filter((assoc) => involvedPaths.has(assoc.leftEntity) ||
|
|
593
|
+
involvedPaths.has(assoc.rightEntity))
|
|
594
|
+
.map((assoc) => ({
|
|
595
|
+
id: assoc.name,
|
|
596
|
+
description: `${extractElementNameFromPath(assoc.leftEntity)} has property '${assoc.leftProperty}' linking to ${extractElementNameFromPath(assoc.rightEntity)}, and ${extractElementNameFromPath(assoc.rightEntity)} has property '${assoc.rightProperty}' linking back to ${extractElementNameFromPath(assoc.leftEntity)}`,
|
|
597
|
+
category: 'association',
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
function buildExecutableNlHints(rootEntity, rootEntityObj, modelContext) {
|
|
601
|
+
if (!modelContext.executables) {
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
const entries = [];
|
|
605
|
+
const matchingExecs = modelContext.executables.filter((ex) => ex.rootEntityPath === rootEntity);
|
|
606
|
+
for (const exec of matchingExecs) {
|
|
607
|
+
if (exec.queryTemplate) {
|
|
608
|
+
entries.push({
|
|
609
|
+
id: `query_template:${exec.title}`,
|
|
610
|
+
description: `Example Pure query for ${rootEntityObj?.name ?? rootEntity}: ${exec.queryTemplate}`,
|
|
611
|
+
category: 'query_template',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (exec.requiredParameters && exec.requiredParameters.length > 0) {
|
|
615
|
+
const paramList = exec.requiredParameters
|
|
616
|
+
.map((p) => `${p.name} (${p.type})`)
|
|
617
|
+
.join(', ');
|
|
618
|
+
entries.push({
|
|
619
|
+
id: `required_params:${exec.title}`,
|
|
620
|
+
description: `This entity requires the following filter parameters: ${paramList}. The generated query MUST include filters for these parameters.`,
|
|
621
|
+
category: 'required_parameters',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (exec.columnPropertyMappings && exec.columnPropertyMappings.length > 0) {
|
|
625
|
+
entries.push({
|
|
626
|
+
id: `column_mappings:${exec.title}`,
|
|
627
|
+
description: `Property name mappings (human-readable → model property): ${exec.columnPropertyMappings
|
|
628
|
+
.slice(0, 20)
|
|
629
|
+
.map((m) => `"${m.columnName}" → ${m.propertyPath}`)
|
|
630
|
+
.join(', ')}`,
|
|
631
|
+
category: 'column_mappings',
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const execSummary = modelContext.executables
|
|
636
|
+
.map((ex) => {
|
|
637
|
+
const entityName = extractElementNameFromPath(ex.rootEntityPath);
|
|
638
|
+
const paramHint = ex.requiredParameters && ex.requiredParameters.length > 0
|
|
639
|
+
? ` (requires: ${ex.requiredParameters.map((p) => p.name).join(', ')})`
|
|
640
|
+
: '';
|
|
641
|
+
return `"${ex.title}" → ${entityName}${paramHint}`;
|
|
642
|
+
})
|
|
643
|
+
.join('; ');
|
|
644
|
+
entries.push({
|
|
645
|
+
id: 'executable_summary',
|
|
646
|
+
description: `Available service executables: ${execSummary}`,
|
|
647
|
+
category: 'executable_summary',
|
|
648
|
+
});
|
|
649
|
+
return entries;
|
|
650
|
+
}
|
|
651
|
+
function findCrossClassPropertyHints(entity, rootEntityObj, rootPropNames, questionTokens, questionLower) {
|
|
652
|
+
const hints = [];
|
|
653
|
+
for (const prop of entity.properties) {
|
|
654
|
+
const propTokens = splitIdentifierTokens(prop.name).filter((t) => t.length > 2);
|
|
655
|
+
const matchScore = propTokens.filter((t) => questionTokens.has(t) || (t.length >= 4 && questionLower.includes(t))).length;
|
|
656
|
+
if (matchScore < 1 || (matchScore < 2 && propTokens.length > 2)) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const rootHasSimilar = rootEntityObj.properties.some((rp) => {
|
|
660
|
+
const rpTokens = splitIdentifierTokens(rp.name).filter((t) => t.length > 2);
|
|
661
|
+
return (rpTokens.filter((t) => propTokens.includes(t)).length >=
|
|
662
|
+
Math.max(1, propTokens.length - 1));
|
|
663
|
+
});
|
|
664
|
+
if (!rootHasSimilar && !rootPropNames.has(prop.name.toLowerCase())) {
|
|
665
|
+
hints.push(`"${prop.name}" exists on ${entity.name} but NOT on ${rootEntityObj.name}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return hints;
|
|
669
|
+
}
|
|
670
|
+
function buildCrossClassNlHints(question, rootEntity, rootEntityObj, modelContext) {
|
|
671
|
+
const questionTokens = new Set(tokenizeText(question));
|
|
672
|
+
const rootPropNames = new Set(rootEntityObj.properties.map((p) => p.name.toLowerCase()));
|
|
673
|
+
const questionLower = question.toLowerCase();
|
|
674
|
+
const crossClassHints = [];
|
|
675
|
+
for (const entity of modelContext.entities) {
|
|
676
|
+
if (entity.path === rootEntity) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
crossClassHints.push(...findCrossClassPropertyHints(entity, rootEntityObj, rootPropNames, questionTokens, questionLower));
|
|
680
|
+
}
|
|
681
|
+
if (crossClassHints.length === 0) {
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
return [
|
|
685
|
+
{
|
|
686
|
+
id: 'cross_class_property_warning',
|
|
687
|
+
description: `WARNING: The following properties mentioned in the question are not available on the resolved entity ${rootEntityObj.name}: ${crossClassHints.slice(0, 5).join('; ')}. Please use only properties available on ${rootEntityObj.name}.`,
|
|
688
|
+
category: 'cross_class_warning',
|
|
689
|
+
},
|
|
690
|
+
];
|
|
691
|
+
}
|
|
692
|
+
function buildNlContextEntries(question, rootEntity, rootEntityObj, relatedEntities, modelContext, entityMap) {
|
|
693
|
+
const entries = [
|
|
694
|
+
...buildRootEntityNlHints(rootEntity, rootEntityObj, modelContext),
|
|
695
|
+
];
|
|
696
|
+
if (modelContext.dataspaceDescription) {
|
|
697
|
+
const shortDesc = modelContext.dataspaceDescription
|
|
698
|
+
.split('\n')
|
|
699
|
+
.filter((l) => !l.startsWith('#') && l.trim().length > 0)
|
|
700
|
+
.slice(0, 3)
|
|
701
|
+
.join(' ')
|
|
702
|
+
.slice(0, 500);
|
|
703
|
+
if (shortDesc.length > 0) {
|
|
704
|
+
entries.push({
|
|
705
|
+
id: 'dataspace',
|
|
706
|
+
description: shortDesc,
|
|
707
|
+
category: 'product_context',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const relPath of relatedEntities) {
|
|
712
|
+
const relEntity = entityMap.get(relPath);
|
|
713
|
+
if (relEntity?.description) {
|
|
714
|
+
entries.push({
|
|
715
|
+
id: relPath,
|
|
716
|
+
description: relEntity.description,
|
|
717
|
+
category: 'related_entity',
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
entries.push(...buildAssociationNlHints(rootEntity, relatedEntities, modelContext), ...buildExecutableNlHints(rootEntity, rootEntityObj, modelContext));
|
|
722
|
+
if (rootEntityObj) {
|
|
723
|
+
entries.push(...buildCrossClassNlHints(question, rootEntity, rootEntityObj, modelContext));
|
|
724
|
+
}
|
|
725
|
+
return entries;
|
|
726
|
+
}
|
|
727
|
+
export function buildEnrichedBusinessContext(question, rootEntity, relatedEntities, modelContext) {
|
|
728
|
+
const entityMap = new Map(modelContext.entities.map((e) => [e.path, e]));
|
|
729
|
+
const rootEntityObj = entityMap.get(rootEntity);
|
|
730
|
+
const properties = [
|
|
731
|
+
...buildRootEntityProperties(rootEntityObj, modelContext),
|
|
732
|
+
...buildRelatedEntityProperties(relatedEntities, entityMap),
|
|
733
|
+
];
|
|
734
|
+
const additionalNlModelContext = buildNlContextEntries(question, rootEntity, rootEntityObj, relatedEntities, modelContext, entityMap);
|
|
735
|
+
const context = {
|
|
736
|
+
naturalLanguageQuery: question,
|
|
737
|
+
};
|
|
738
|
+
if (properties.length > 0 || additionalNlModelContext.length > 0) {
|
|
739
|
+
context.businessContextMatch = {};
|
|
740
|
+
if (properties.length > 0) {
|
|
741
|
+
context.businessContextMatch.properties = properties;
|
|
742
|
+
}
|
|
743
|
+
if (additionalNlModelContext.length > 0) {
|
|
744
|
+
context.businessContextMatch.additionalNlModelContext =
|
|
745
|
+
additionalNlModelContext;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return context;
|
|
749
|
+
}
|
|
750
|
+
const PRIMITIVE_TYPE_NAMES = new Set([
|
|
751
|
+
PRIMITIVE_TYPE.STRING,
|
|
752
|
+
PRIMITIVE_TYPE.INTEGER,
|
|
753
|
+
PRIMITIVE_TYPE.FLOAT,
|
|
754
|
+
PRIMITIVE_TYPE.DECIMAL,
|
|
755
|
+
PRIMITIVE_TYPE.BOOLEAN,
|
|
756
|
+
PRIMITIVE_TYPE.DATE,
|
|
757
|
+
PRIMITIVE_TYPE.STRICTDATE,
|
|
758
|
+
PRIMITIVE_TYPE.DATETIME,
|
|
759
|
+
PRIMITIVE_TYPE.NUMBER,
|
|
760
|
+
]);
|
|
761
|
+
function isPrimitiveType(type) {
|
|
762
|
+
return PRIMITIVE_TYPE_NAMES.has(type);
|
|
763
|
+
}
|
|
764
|
+
// Acronym boundary regex (bounded repetition to prevent catastrophic backtracking
|
|
765
|
+
// on adversarial inputs). Inserts a space before the last uppercase letter of an
|
|
766
|
+
// acronym when followed by a lowercase letter — e.g. "XMLParser" → "XML Parser".
|
|
767
|
+
const ACRONYM_BOUNDARY_PATTERN = /(?<acr>[A-Z]{1,32})(?<head>[A-Z][a-z])/g;
|
|
768
|
+
/**
|
|
769
|
+
* Splits a camelCase, PascalCase, or snake_case identifier into lowercase
|
|
770
|
+
* tokens for fuzzy matching. e.g. "taxIDTypeCD" → ["tax", "id", "type", "cd"],
|
|
771
|
+
* "FISCAL_YEAR_START" → ["fiscal", "year", "start"].
|
|
772
|
+
*/
|
|
773
|
+
export function splitIdentifierTokens(name) {
|
|
774
|
+
return name
|
|
775
|
+
.replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/g, '$<lower> $<upper>')
|
|
776
|
+
.replaceAll(/[_\-./]+/g, ' ')
|
|
777
|
+
.replaceAll(ACRONYM_BOUNDARY_PATTERN, '$<acr> $<head>')
|
|
778
|
+
.toLowerCase()
|
|
779
|
+
.split(/\s+/)
|
|
780
|
+
.filter((t) => t.length > 1);
|
|
781
|
+
}
|
|
782
|
+
const TEXT_TOKEN_SPLIT = /[\s,.:;!?'"()\-/]+/;
|
|
783
|
+
const DEFAULT_MIN_TOKEN_LENGTH = 3;
|
|
784
|
+
/**
|
|
785
|
+
* Canonical natural-language tokenizer for the Legend AI pipeline. Splits on
|
|
786
|
+
* standard punctuation/whitespace, lowercases, drops short tokens, and
|
|
787
|
+
* optionally filters a stopword set. Used by BM25 indexing, coverage
|
|
788
|
+
* checks, and cross-class hint matching to keep tokenization consistent.
|
|
789
|
+
*/
|
|
790
|
+
export function tokenizeText(text, options) {
|
|
791
|
+
const minLength = options?.minLength ?? DEFAULT_MIN_TOKEN_LENGTH;
|
|
792
|
+
const stopwords = options?.stopwords;
|
|
793
|
+
return text
|
|
794
|
+
.toLowerCase()
|
|
795
|
+
.split(TEXT_TOKEN_SPLIT)
|
|
796
|
+
.filter((t) => t.length >= minLength && !(stopwords?.has(t) ?? false));
|
|
797
|
+
}
|
|
798
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
799
|
+
// Association-aware alternate root selection (Phase 3)
|
|
800
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
801
|
+
/**
|
|
802
|
+
* Picks the best alternate root entity when a query returns zero results.
|
|
803
|
+
* Ranks candidates by their association connectivity — entities with more
|
|
804
|
+
* connections to other model entities are more likely to have queryable data.
|
|
805
|
+
* A direct association with the failed root gets a bonus so closely-related
|
|
806
|
+
* entities are preferred.
|
|
807
|
+
*/
|
|
808
|
+
export function findBestAlternateRoot(failedRoot, relatedEntities, modelContext) {
|
|
809
|
+
if (relatedEntities.length === 0) {
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
if (relatedEntities.length === 1 || modelContext.associations.length === 0) {
|
|
813
|
+
return relatedEntities[0];
|
|
814
|
+
}
|
|
815
|
+
const scores = relatedEntities.map((entity) => {
|
|
816
|
+
let score = 0;
|
|
817
|
+
for (const assoc of modelContext.associations) {
|
|
818
|
+
const isInvolved = assoc.leftEntity === entity || assoc.rightEntity === entity;
|
|
819
|
+
if (isInvolved) {
|
|
820
|
+
score += 1;
|
|
821
|
+
if (assoc.leftEntity === failedRoot ||
|
|
822
|
+
assoc.rightEntity === failedRoot) {
|
|
823
|
+
score += 2;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return { entity, score };
|
|
828
|
+
});
|
|
829
|
+
scores.sort((a, b) => b.score - a.score);
|
|
830
|
+
return scores[0]?.entity ?? relatedEntities[0];
|
|
831
|
+
}
|
|
832
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
833
|
+
// Semantic property-doc index for RAG-like matching
|
|
834
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
835
|
+
function addTokensToIndex(index, tokens, entityPath) {
|
|
836
|
+
for (const token of tokens) {
|
|
837
|
+
let set = index.get(token);
|
|
838
|
+
if (!set) {
|
|
839
|
+
set = new Set();
|
|
840
|
+
index.set(token, set);
|
|
841
|
+
}
|
|
842
|
+
set.add(entityPath);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
export function buildSemanticPropertyIndex(modelContext) {
|
|
846
|
+
const index = new Map();
|
|
847
|
+
for (const entity of modelContext.entities) {
|
|
848
|
+
for (const prop of entity.properties) {
|
|
849
|
+
const nameTokens = prop.name
|
|
850
|
+
.replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/g, '$<lower> $<upper>')
|
|
851
|
+
.toLowerCase()
|
|
852
|
+
.split(/[\s_]+/)
|
|
853
|
+
.filter((t) => t.length > 2);
|
|
854
|
+
addTokensToIndex(index, nameTokens, entity.path);
|
|
855
|
+
}
|
|
856
|
+
if (entity.description) {
|
|
857
|
+
const descTokens = entity.description
|
|
858
|
+
.toLowerCase()
|
|
859
|
+
.split(/[\s,.;:!?'"()\-/]+/)
|
|
860
|
+
.filter((t) => t.length > 2);
|
|
861
|
+
addTokensToIndex(index, descTokens, entity.path);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return index;
|
|
865
|
+
}
|
|
866
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
867
|
+
// Deterministic entity resolution (no LLM required)
|
|
868
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
869
|
+
/**
|
|
870
|
+
* Resolves root and related entities using keyword matching against the model
|
|
871
|
+
* context. This works without any LLM call and is used as the final fallback
|
|
872
|
+
* when both LLM-based resolution and marketplace search are unavailable.
|
|
873
|
+
*
|
|
874
|
+
* Scoring:
|
|
875
|
+
* - entity name match (+5)
|
|
876
|
+
* - property name match (+2)
|
|
877
|
+
* - isQueryable bonus (+10) — entity is directly queried by a service
|
|
878
|
+
* - isRootMapped bonus (+3)
|
|
879
|
+
* - description keyword match (+1)
|
|
880
|
+
* - executable title/description match (+4)
|
|
881
|
+
* - semantic property-doc match (+1 per token hit)
|
|
882
|
+
*
|
|
883
|
+
* When queryable entities exist, only they are eligible as root.
|
|
884
|
+
*/
|
|
885
|
+
function pickFallbackEntity(queryableEntities, entities) {
|
|
886
|
+
const preferred = queryableEntities[0] ?? entities.find((e) => e.isRootMapped) ?? entities[0];
|
|
887
|
+
return preferred
|
|
888
|
+
? { rootEntity: preferred.path, relatedEntities: [] }
|
|
889
|
+
: undefined;
|
|
890
|
+
}
|
|
891
|
+
function buildExecutableByRootIndex(modelContext) {
|
|
892
|
+
const index = new Map();
|
|
893
|
+
for (const exec of modelContext.executables ?? []) {
|
|
894
|
+
let words = index.get(exec.rootEntityPath);
|
|
895
|
+
if (!words) {
|
|
896
|
+
words = [];
|
|
897
|
+
index.set(exec.rootEntityPath, words);
|
|
898
|
+
}
|
|
899
|
+
words.push(...`${exec.title} ${exec.description ?? ''}`
|
|
900
|
+
.toLowerCase()
|
|
901
|
+
.split(/[\s,.;:!?'"()\-/]+/)
|
|
902
|
+
.filter((t) => t.length > 2));
|
|
903
|
+
}
|
|
904
|
+
return index;
|
|
905
|
+
}
|
|
906
|
+
function scoreEntityByProperties(entity, tokens) {
|
|
907
|
+
let score = 0;
|
|
908
|
+
for (const prop of entity.properties) {
|
|
909
|
+
const propLower = prop.name.toLowerCase();
|
|
910
|
+
for (const token of tokens) {
|
|
911
|
+
if (propLower === token || propLower.includes(token)) {
|
|
912
|
+
score += 2;
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return score;
|
|
918
|
+
}
|
|
919
|
+
function scoreEntity(entity, tokens, semanticIndex, executableByRoot) {
|
|
920
|
+
const nameLower = entity.name.toLowerCase();
|
|
921
|
+
let score = tokens.filter((t) => nameLower === t || nameLower.includes(t)).length * 5;
|
|
922
|
+
score += scoreEntityByProperties(entity, tokens);
|
|
923
|
+
if (entity.isQueryable) {
|
|
924
|
+
score += 10;
|
|
925
|
+
}
|
|
926
|
+
if (entity.isRootMapped) {
|
|
927
|
+
score += 3;
|
|
928
|
+
}
|
|
929
|
+
if (entity.description) {
|
|
930
|
+
const descLower = entity.description.toLowerCase();
|
|
931
|
+
score += tokens.filter((t) => descLower.includes(t)).length;
|
|
932
|
+
}
|
|
933
|
+
const execWords = executableByRoot.get(entity.path);
|
|
934
|
+
if (execWords) {
|
|
935
|
+
score +=
|
|
936
|
+
tokens.filter((t) => execWords.some((w) => w === t || w.includes(t)))
|
|
937
|
+
.length * 4;
|
|
938
|
+
}
|
|
939
|
+
score += tokens.filter((t) => semanticIndex.get(t)?.has(entity.path)).length;
|
|
940
|
+
return score;
|
|
941
|
+
}
|
|
942
|
+
export function resolveEntitiesDeterministic(question, modelContext) {
|
|
943
|
+
if (modelContext.entities.length === 0) {
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
if (modelContext.entities.length === 1) {
|
|
947
|
+
const singleEntity = modelContext.entities[0];
|
|
948
|
+
if (singleEntity) {
|
|
949
|
+
return { rootEntity: singleEntity.path, relatedEntities: [] };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const queryableEntities = modelContext.entities.filter((e) => e.isQueryable);
|
|
953
|
+
const executableByRoot = buildExecutableByRootIndex(modelContext);
|
|
954
|
+
const semanticIndex = buildSemanticPropertyIndex(modelContext);
|
|
955
|
+
const tokens = question
|
|
956
|
+
.toLowerCase()
|
|
957
|
+
.split(/[\s,.;:!?'"()\-/]+/)
|
|
958
|
+
.filter((t) => t.length > 2);
|
|
959
|
+
if (tokens.length === 0) {
|
|
960
|
+
return pickFallbackEntity(queryableEntities, modelContext.entities);
|
|
961
|
+
}
|
|
962
|
+
const scored = modelContext.entities
|
|
963
|
+
.map((entity) => ({
|
|
964
|
+
entity,
|
|
965
|
+
score: scoreEntity(entity, tokens, semanticIndex, executableByRoot),
|
|
966
|
+
}))
|
|
967
|
+
.sort((a, b) => b.score - a.score);
|
|
968
|
+
const rootCandidates = queryableEntities.length > 0
|
|
969
|
+
? scored.filter((s) => s.entity.isQueryable)
|
|
970
|
+
: scored;
|
|
971
|
+
const winner = rootCandidates[0];
|
|
972
|
+
if (!winner || winner.score === 0) {
|
|
973
|
+
return pickFallbackEntity(queryableEntities, modelContext.entities);
|
|
974
|
+
}
|
|
975
|
+
const rootPath = winner.entity.path;
|
|
976
|
+
const relatedSet = new Set();
|
|
977
|
+
for (const assoc of modelContext.associations) {
|
|
978
|
+
if (assoc.leftEntity === rootPath) {
|
|
979
|
+
relatedSet.add(assoc.rightEntity);
|
|
980
|
+
}
|
|
981
|
+
else if (assoc.rightEntity === rootPath) {
|
|
982
|
+
relatedSet.add(assoc.leftEntity);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
for (const s of scored.slice(1)) {
|
|
986
|
+
if (s.score > 0 && relatedSet.size < 5) {
|
|
987
|
+
relatedSet.add(s.entity.path);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
rootEntity: rootPath,
|
|
992
|
+
relatedEntities: Array.from(relatedSet).slice(0, 5),
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
996
|
+
// Model context enrichment for SQL / metadata prompts
|
|
997
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
998
|
+
const MAX_ENRICHMENT_ENTITIES = 8;
|
|
999
|
+
const MAX_ENRICHMENT_PROPS_PER_ENTITY = 15;
|
|
1000
|
+
const MAX_ENRICHMENT_ENUMS = 10;
|
|
1001
|
+
const MAX_ENRICHMENT_ENUM_VALUES = 20;
|
|
1002
|
+
const MAX_ENRICHMENT_ASSOCIATIONS = 10;
|
|
1003
|
+
/**
|
|
1004
|
+
* Maps each TDS service to the model entity whose properties best match
|
|
1005
|
+
* the service's column names. Uses a simple overlap-scoring approach:
|
|
1006
|
+
* for each (service, entity) pair, count how many service column names
|
|
1007
|
+
* match entity property names (case-insensitive). The entity with the
|
|
1008
|
+
* highest overlap wins, provided it exceeds a minimum threshold.
|
|
1009
|
+
*
|
|
1010
|
+
* Returns a Map<serviceTitle, entityPath>.
|
|
1011
|
+
*/
|
|
1012
|
+
function inferServiceEntityMapping(services, modelContext) {
|
|
1013
|
+
const result = new Map();
|
|
1014
|
+
const MIN_OVERLAP = 2; // require at least 2 matching columns
|
|
1015
|
+
const entityPropSets = modelContext.entities.map((entity) => ({
|
|
1016
|
+
path: entity.path,
|
|
1017
|
+
props: new Set(entity.properties.map((p) => p.name.toLowerCase())),
|
|
1018
|
+
}));
|
|
1019
|
+
for (const svc of services) {
|
|
1020
|
+
const colNames = svc.columns.map((c) => c.name.toLowerCase());
|
|
1021
|
+
let bestEntity = '';
|
|
1022
|
+
let bestScore = 0;
|
|
1023
|
+
for (const { path, props } of entityPropSets) {
|
|
1024
|
+
const overlap = colNames.filter((c) => props.has(c)).length;
|
|
1025
|
+
if (overlap > bestScore) {
|
|
1026
|
+
bestScore = overlap;
|
|
1027
|
+
bestEntity = path;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (bestScore >= MIN_OVERLAP && bestEntity) {
|
|
1031
|
+
result.set(svc.title, bestEntity);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
function buildProductDescriptionSection(modelContext) {
|
|
1037
|
+
if (!modelContext.dataspaceDescription) {
|
|
1038
|
+
return undefined;
|
|
1039
|
+
}
|
|
1040
|
+
const shortDesc = modelContext.dataspaceDescription
|
|
1041
|
+
.split('\n')
|
|
1042
|
+
.filter((l) => !l.startsWith('#') && l.trim().length > 0)
|
|
1043
|
+
.slice(0, 3)
|
|
1044
|
+
.join(' ')
|
|
1045
|
+
.slice(0, 500);
|
|
1046
|
+
return shortDesc.length > 0
|
|
1047
|
+
? `## Data Model Overview\n${shortDesc}`
|
|
1048
|
+
: undefined;
|
|
1049
|
+
}
|
|
1050
|
+
function buildEntitySection(modelContext) {
|
|
1051
|
+
if (modelContext.entities.length === 0) {
|
|
1052
|
+
return undefined;
|
|
1053
|
+
}
|
|
1054
|
+
const sorted = [...modelContext.entities].sort((a, b) => {
|
|
1055
|
+
const aScore = (a.isQueryable ? 10 : 0) + (a.isRootMapped ? 3 : 0);
|
|
1056
|
+
const bScore = (b.isQueryable ? 10 : 0) + (b.isRootMapped ? 3 : 0);
|
|
1057
|
+
return bScore - aScore;
|
|
1058
|
+
});
|
|
1059
|
+
const lines = ['## Model Entities'];
|
|
1060
|
+
for (const entity of sorted.slice(0, MAX_ENRICHMENT_ENTITIES)) {
|
|
1061
|
+
const tags = [];
|
|
1062
|
+
if (entity.isQueryable) {
|
|
1063
|
+
tags.push('QUERYABLE');
|
|
1064
|
+
}
|
|
1065
|
+
if (entity.isRootMapped) {
|
|
1066
|
+
tags.push('ROOT_MAPPED');
|
|
1067
|
+
}
|
|
1068
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
1069
|
+
lines.push(`\n### ${entity.name}${tagStr}`);
|
|
1070
|
+
if (entity.description) {
|
|
1071
|
+
lines.push(entity.description.slice(0, 300));
|
|
1072
|
+
}
|
|
1073
|
+
const propLines = entity.properties
|
|
1074
|
+
.slice(0, MAX_ENRICHMENT_PROPS_PER_ENTITY)
|
|
1075
|
+
.map((p) => `- ${p.name}: ${p.type}${p.isCollection ? ' (many)' : ''}`);
|
|
1076
|
+
if (propLines.length > 0) {
|
|
1077
|
+
lines.push('Properties:', ...propLines);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (modelContext.entities.length > MAX_ENRICHMENT_ENTITIES) {
|
|
1081
|
+
lines.push(`\n(${modelContext.entities.length - MAX_ENRICHMENT_ENTITIES} additional entities omitted)`);
|
|
1082
|
+
}
|
|
1083
|
+
return lines.join('\n');
|
|
1084
|
+
}
|
|
1085
|
+
function buildEnumerationSection(modelContext) {
|
|
1086
|
+
if (!modelContext.enumerations?.length) {
|
|
1087
|
+
return undefined;
|
|
1088
|
+
}
|
|
1089
|
+
const lines = ['## Enumerations (valid filter values)'];
|
|
1090
|
+
for (const en of modelContext.enumerations.slice(0, MAX_ENRICHMENT_ENUMS)) {
|
|
1091
|
+
const vals = en.values.slice(0, MAX_ENRICHMENT_ENUM_VALUES).join(', ');
|
|
1092
|
+
const suffix = en.values.length > MAX_ENRICHMENT_ENUM_VALUES
|
|
1093
|
+
? `, ... (${en.values.length} total)`
|
|
1094
|
+
: '';
|
|
1095
|
+
lines.push(`- ${extractElementNameFromPath(en.path)}: ${vals}${suffix}`);
|
|
1096
|
+
}
|
|
1097
|
+
return lines.join('\n');
|
|
1098
|
+
}
|
|
1099
|
+
function buildAssociationSection(modelContext) {
|
|
1100
|
+
if (modelContext.associations.length === 0) {
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
const lines = ['## Entity Relationships'];
|
|
1104
|
+
for (const assoc of modelContext.associations.slice(0, MAX_ENRICHMENT_ASSOCIATIONS)) {
|
|
1105
|
+
const l = extractElementNameFromPath(assoc.leftEntity);
|
|
1106
|
+
const r = extractElementNameFromPath(assoc.rightEntity);
|
|
1107
|
+
lines.push(`- ${l}.${assoc.leftProperty} → ${r}, ${r}.${assoc.rightProperty} → ${l}`);
|
|
1108
|
+
}
|
|
1109
|
+
return lines.join('\n');
|
|
1110
|
+
}
|
|
1111
|
+
function buildExecutableSection(modelContext) {
|
|
1112
|
+
if (!modelContext.executables?.length) {
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
const lines = ['## Available Executables'];
|
|
1116
|
+
for (const exec of modelContext.executables) {
|
|
1117
|
+
lines.push(`\n- "${exec.title}" → ${extractElementNameFromPath(exec.rootEntityPath)}`);
|
|
1118
|
+
if (exec.description) {
|
|
1119
|
+
lines.push(` ${exec.description.slice(0, 200)}`);
|
|
1120
|
+
}
|
|
1121
|
+
if (exec.requiredParameters && exec.requiredParameters.length > 0) {
|
|
1122
|
+
const paramList = exec.requiredParameters
|
|
1123
|
+
.map((p) => `${p.name} (${p.type})`)
|
|
1124
|
+
.join(', ');
|
|
1125
|
+
lines.push(` Required parameters: ${paramList}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return lines.join('\n');
|
|
1129
|
+
}
|
|
1130
|
+
function buildPropToEnumPathMap(modelContext, enumPathMap) {
|
|
1131
|
+
const propToEnumPath = new Map();
|
|
1132
|
+
for (const entity of modelContext.entities) {
|
|
1133
|
+
for (const prop of entity.properties) {
|
|
1134
|
+
if (enumPathMap.has(prop.type)) {
|
|
1135
|
+
propToEnumPath.set(prop.name.toLowerCase(), prop.type);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return propToEnumPath;
|
|
1140
|
+
}
|
|
1141
|
+
function buildColumnEnumLine(col, propToEnumPath, enumPathMap) {
|
|
1142
|
+
const enumPath = propToEnumPath.get(col.name.toLowerCase());
|
|
1143
|
+
if (!enumPath) {
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
const enumValues = enumPathMap.get(enumPath);
|
|
1147
|
+
if (!enumValues?.length) {
|
|
1148
|
+
return undefined;
|
|
1149
|
+
}
|
|
1150
|
+
const vals = enumValues.slice(0, MAX_ENRICHMENT_ENUM_VALUES).join(', ');
|
|
1151
|
+
const suffix = enumValues.length > MAX_ENRICHMENT_ENUM_VALUES
|
|
1152
|
+
? `, ... (${enumValues.length} total)`
|
|
1153
|
+
: '';
|
|
1154
|
+
return `- Column "${col.name}" accepts: ${vals}${suffix}`;
|
|
1155
|
+
}
|
|
1156
|
+
function buildColumnEnumMappingSection(modelContext, services) {
|
|
1157
|
+
if (!services?.length || !modelContext.enumerations?.length) {
|
|
1158
|
+
return undefined;
|
|
1159
|
+
}
|
|
1160
|
+
const enumPathMap = new Map(modelContext.enumerations.map((en) => [en.path, en.values]));
|
|
1161
|
+
const propToEnumPath = buildPropToEnumPathMap(modelContext, enumPathMap);
|
|
1162
|
+
if (propToEnumPath.size === 0) {
|
|
1163
|
+
return undefined;
|
|
1164
|
+
}
|
|
1165
|
+
const seen = new Set();
|
|
1166
|
+
const mappingLines = [];
|
|
1167
|
+
for (const svc of services) {
|
|
1168
|
+
for (const col of svc.columns) {
|
|
1169
|
+
const colLower = col.name.toLowerCase();
|
|
1170
|
+
if (seen.has(colLower)) {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
const line = buildColumnEnumLine(col, propToEnumPath, enumPathMap);
|
|
1174
|
+
if (line) {
|
|
1175
|
+
mappingLines.push(line);
|
|
1176
|
+
seen.add(colLower);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (mappingLines.length === 0) {
|
|
1181
|
+
return undefined;
|
|
1182
|
+
}
|
|
1183
|
+
return [
|
|
1184
|
+
'## Column Filter Value Mappings',
|
|
1185
|
+
'When filtering on these columns, use ONLY the exact values listed below:',
|
|
1186
|
+
...mappingLines,
|
|
1187
|
+
].join('\n');
|
|
1188
|
+
}
|
|
1189
|
+
function buildPropTypeDescMap(modelContext) {
|
|
1190
|
+
const map = new Map();
|
|
1191
|
+
for (const entity of modelContext.entities) {
|
|
1192
|
+
for (const prop of entity.properties) {
|
|
1193
|
+
if (!map.has(prop.name.toLowerCase())) {
|
|
1194
|
+
map.set(prop.name.toLowerCase(), isPrimitiveType(prop.type)
|
|
1195
|
+
? prop.type
|
|
1196
|
+
: extractElementNameFromPath(prop.type));
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return map;
|
|
1201
|
+
}
|
|
1202
|
+
function buildColumnDescriptionSection(modelContext, services) {
|
|
1203
|
+
if (!services?.length || !modelContext.entities.length) {
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
const propDescMap = buildPropTypeDescMap(modelContext);
|
|
1207
|
+
const seen = new Set();
|
|
1208
|
+
const undocumented = [];
|
|
1209
|
+
for (const svc of services) {
|
|
1210
|
+
for (const col of svc.columns) {
|
|
1211
|
+
const colLower = col.name.toLowerCase();
|
|
1212
|
+
if (col.documentation || seen.has(colLower)) {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
const modelType = propDescMap.get(colLower);
|
|
1216
|
+
if (modelType && modelType !== (col.type ?? '')) {
|
|
1217
|
+
undocumented.push(`- "${col.name}" corresponds to model property type ${modelType}`);
|
|
1218
|
+
seen.add(colLower);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (undocumented.length === 0) {
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
return [
|
|
1226
|
+
'## Column-to-Model Property Hints',
|
|
1227
|
+
...undocumented.slice(0, 20),
|
|
1228
|
+
].join('\n');
|
|
1229
|
+
}
|
|
1230
|
+
function buildServicePairHint(svcA, entityA, svcB, entityB, modelContext, services) {
|
|
1231
|
+
if (entityA === entityB) {
|
|
1232
|
+
return `\n- **${svcA}** and **${svcB}** query the SAME entity — JOIN on any shared identifier column.`;
|
|
1233
|
+
}
|
|
1234
|
+
const assoc = modelContext.associations.find((a) => (a.leftEntity === entityA && a.rightEntity === entityB) ||
|
|
1235
|
+
(a.leftEntity === entityB && a.rightEntity === entityA));
|
|
1236
|
+
if (!assoc) {
|
|
1237
|
+
return undefined;
|
|
1238
|
+
}
|
|
1239
|
+
const leftName = extractElementNameFromPath(assoc.leftEntity);
|
|
1240
|
+
const rightName = extractElementNameFromPath(assoc.rightEntity);
|
|
1241
|
+
const colsA = new Set(services
|
|
1242
|
+
.find((s) => s.title === svcA)
|
|
1243
|
+
?.columns.map((c) => c.name.toLowerCase()) ?? []);
|
|
1244
|
+
const svcBCols = services.find((s) => s.title === svcB)?.columns ?? [];
|
|
1245
|
+
const shared = svcBCols
|
|
1246
|
+
.filter((c) => colsA.has(c.name.toLowerCase()))
|
|
1247
|
+
.map((c) => c.name);
|
|
1248
|
+
const joinKeyHint = shared.length > 0
|
|
1249
|
+
? `JOIN ON shared columns: ${shared.join(', ')}`
|
|
1250
|
+
: `No shared columns by name — match on equivalent business keys (e.g. ${leftName} ID ↔ ${rightName} foreign key)`;
|
|
1251
|
+
return `\n- **${svcA}** ↔ **${svcB}**: Related via ${leftName}.${assoc.leftProperty} → ${rightName}. ${joinKeyHint}`;
|
|
1252
|
+
}
|
|
1253
|
+
function buildServicePairHints(serviceEntityMap, modelContext, services) {
|
|
1254
|
+
const svcEntries = Array.from(serviceEntityMap.entries());
|
|
1255
|
+
const pairHints = [];
|
|
1256
|
+
for (let i = 0; i < svcEntries.length; i++) {
|
|
1257
|
+
for (let j = i + 1; j < svcEntries.length; j++) {
|
|
1258
|
+
const entryA = svcEntries[i];
|
|
1259
|
+
const entryB = svcEntries[j];
|
|
1260
|
+
if (!entryA || !entryB) {
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
const [svcA, entityA] = entryA;
|
|
1264
|
+
const [svcB, entityB] = entryB;
|
|
1265
|
+
const hint = buildServicePairHint(svcA, entityA, svcB, entityB, modelContext, services);
|
|
1266
|
+
if (hint) {
|
|
1267
|
+
pairHints.push(hint);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return pairHints;
|
|
1272
|
+
}
|
|
1273
|
+
function buildServiceJoinSection(modelContext, services) {
|
|
1274
|
+
if (!services || services.length < 2) {
|
|
1275
|
+
return undefined;
|
|
1276
|
+
}
|
|
1277
|
+
if (!modelContext.entities.length || !modelContext.associations.length) {
|
|
1278
|
+
return undefined;
|
|
1279
|
+
}
|
|
1280
|
+
const serviceEntityMap = inferServiceEntityMapping(services, modelContext);
|
|
1281
|
+
if (serviceEntityMap.size === 0) {
|
|
1282
|
+
return undefined;
|
|
1283
|
+
}
|
|
1284
|
+
const joinLines = [
|
|
1285
|
+
'## Model-Aware Service JOIN Guide',
|
|
1286
|
+
'Each service below is mapped to the data model entity whose properties best match its columns.',
|
|
1287
|
+
'Use the entity relationships to determine correct JOIN keys and semantics.',
|
|
1288
|
+
'',
|
|
1289
|
+
];
|
|
1290
|
+
for (const [svcTitle, entityPath] of serviceEntityMap) {
|
|
1291
|
+
joinLines.push(`- **${svcTitle}** → entity ${extractElementNameFromPath(entityPath)}`);
|
|
1292
|
+
}
|
|
1293
|
+
const pairHints = buildServicePairHints(serviceEntityMap, modelContext, services);
|
|
1294
|
+
if (pairHints.length > 0) {
|
|
1295
|
+
joinLines.push('', '### Inter-Service Relationships', ...pairHints);
|
|
1296
|
+
}
|
|
1297
|
+
return joinLines.join('\n');
|
|
1298
|
+
}
|
|
1299
|
+
export function buildModelContextEnrichmentText(modelContext, services) {
|
|
1300
|
+
const sections = [
|
|
1301
|
+
buildProductDescriptionSection(modelContext),
|
|
1302
|
+
buildEntitySection(modelContext),
|
|
1303
|
+
buildEnumerationSection(modelContext),
|
|
1304
|
+
buildAssociationSection(modelContext),
|
|
1305
|
+
buildExecutableSection(modelContext),
|
|
1306
|
+
buildColumnEnumMappingSection(modelContext, services),
|
|
1307
|
+
buildColumnDescriptionSection(modelContext, services),
|
|
1308
|
+
buildServiceJoinSection(modelContext, services),
|
|
1309
|
+
].filter((s) => s !== undefined);
|
|
1310
|
+
if (sections.length === 0) {
|
|
1311
|
+
return undefined;
|
|
1312
|
+
}
|
|
1313
|
+
return `# DATA MODEL CONTEXT
|
|
1314
|
+
The following describes the underlying data model — entities, properties, enumerations, and relationships. Use this to understand column semantics, valid filter values, and how entities relate to each other.
|
|
1315
|
+
|
|
1316
|
+
${sections.join('\n\n')}`;
|
|
1317
|
+
}
|
|
429
1318
|
//# sourceMappingURL=LegendAIDocEnrichment.js.map
|