@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
@@ -14,10 +14,15 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import type { Multiplicity } from '@finos/legend-graph';
17
+ import {
18
+ type Multiplicity,
19
+ extractElementNameFromPath,
20
+ PRIMITIVE_TYPE,
21
+ } from '@finos/legend-graph';
18
22
  import {
19
23
  ClassDocumentationEntry,
20
24
  AssociationDocumentationEntry,
25
+ EnumerationDocumentationEntry,
21
26
  PropertyDocumentationEntry,
22
27
  type NormalizedDocumentationEntry,
23
28
  } from '../model-documentation/index.js';
@@ -26,7 +31,16 @@ import type {
26
31
  TDSServiceSchema,
27
32
  TDSServicePreFilter,
28
33
  LegendAIServiceRelationship,
34
+ LegendAIModelContext,
35
+ LegendAIModelEntity,
36
+ LegendAIModelAssociation,
37
+ LegendAIModelProperty,
29
38
  } from './LegendAITypes.js';
39
+ import type {
40
+ LegendAIEnrichedBusinessContext,
41
+ LegendAIBusinessContextProperty,
42
+ LegendAIAdditionalNlModelContext,
43
+ } from './LegendAI_LegendApplicationPlugin_Extension.js';
30
44
 
31
45
  /**
32
46
  * Builds a lookup map from lowercase property name to its documentation entry.
@@ -570,3 +584,1186 @@ export function formatPreFiltersForContext(
570
584
  }
571
585
  return `Pre-applied filters: ${parts.join('; ')}`;
572
586
  }
587
+
588
+ // ────────────────────────────────────────────────────────────────────────────
589
+ // Model context extraction from DataSpace elementDocs
590
+ // ────────────────────────────────────────────────────────────────────────────
591
+
592
+ /**
593
+ * Extracts a structured model context from DataSpace elementDocs.
594
+ * The resulting {@link LegendAIModelContext} provides all class entities,
595
+ * associations, and enumerations needed for local entity resolution in the
596
+ * orchestrator flow — eliminating the need for a marketplace search API call.
597
+ *
598
+ * Only applicable to DataSpaces (not DataProducts, which lack elementDocs).
599
+ */
600
+ function collectClassEntities(
601
+ classMap: Map<string, ClassDocumentationEntry>,
602
+ ): LegendAIModelEntity[] {
603
+ return Array.from(classMap.values()).map((cls) => {
604
+ const properties: LegendAIModelProperty[] = cls.properties.map((prop) => {
605
+ const upperBound = prop.multiplicity?.upperBound;
606
+ return {
607
+ name: prop.name,
608
+ type: prop.type ?? 'Unknown',
609
+ isCollection: upperBound === undefined || upperBound > 1,
610
+ isOptional: (prop.multiplicity?.lowerBound ?? 0) === 0,
611
+ };
612
+ });
613
+ const entity: LegendAIModelEntity = {
614
+ path: cls.path,
615
+ name: cls.name,
616
+ properties,
617
+ };
618
+ if (cls.docs.length > 0) {
619
+ entity.description = cls.docs.join('; ');
620
+ }
621
+ return entity;
622
+ });
623
+ }
624
+
625
+ function collectAssociations(
626
+ assocMap: Map<string, AssociationDocumentationEntry>,
627
+ ): LegendAIModelAssociation[] {
628
+ const associations: LegendAIModelAssociation[] = [];
629
+ for (const assoc of assocMap.values()) {
630
+ if (assoc.properties.length !== 2) {
631
+ continue;
632
+ }
633
+ const [propA, propB] = assoc.properties;
634
+ if (propA?.type && propB?.type) {
635
+ associations.push({
636
+ name: assoc.name,
637
+ leftEntity: propA.type,
638
+ leftProperty: propA.name,
639
+ rightEntity: propB.type,
640
+ rightProperty: propB.name,
641
+ });
642
+ }
643
+ }
644
+ return associations;
645
+ }
646
+
647
+ function buildUniqueEntryMap<T extends { path: string }>(
648
+ elementDocs: NormalizedDocumentationEntry[],
649
+ EntryClass: new (...args: never[]) => T,
650
+ ): Map<string, T> {
651
+ const map = new Map<string, T>();
652
+ for (const entry of elementDocs) {
653
+ if (
654
+ entry.elementEntry instanceof EntryClass &&
655
+ !map.has(entry.elementEntry.path)
656
+ ) {
657
+ map.set(entry.elementEntry.path, entry.elementEntry);
658
+ }
659
+ }
660
+ return map;
661
+ }
662
+
663
+ export function extractModelContext(
664
+ elementDocs: NormalizedDocumentationEntry[],
665
+ ): LegendAIModelContext {
666
+ const entities = collectClassEntities(
667
+ buildUniqueEntryMap(elementDocs, ClassDocumentationEntry),
668
+ );
669
+ const associations = collectAssociations(
670
+ buildUniqueEntryMap(elementDocs, AssociationDocumentationEntry),
671
+ );
672
+ const enumMap = buildUniqueEntryMap(
673
+ elementDocs,
674
+ EnumerationDocumentationEntry,
675
+ );
676
+ const enumerations =
677
+ enumMap.size > 0
678
+ ? Array.from(enumMap.values()).map((e) => ({
679
+ path: e.path,
680
+ name: e.name,
681
+ values: e.enumValues.map((v) => v.name),
682
+ }))
683
+ : undefined;
684
+
685
+ const result: LegendAIModelContext = { entities, associations };
686
+ if (enumerations) {
687
+ result.enumerations = enumerations;
688
+ }
689
+ return result;
690
+ }
691
+
692
+ // ────────────────────────────────────────────────────────────────────────────
693
+ // Enriched business context for orchestrator payload
694
+ // ────────────────────────────────────────────────────────────────────────────
695
+
696
+ const MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES = 10;
697
+
698
+ function buildRootEntityProperties(
699
+ rootEntityObj: LegendAIModelEntity | undefined,
700
+ modelContext: LegendAIModelContext,
701
+ ): LegendAIBusinessContextProperty[] {
702
+ if (!rootEntityObj) {
703
+ return [];
704
+ }
705
+ return rootEntityObj.properties.map((prop) => {
706
+ const entry: LegendAIBusinessContextProperty = { propertyName: prop.name };
707
+ const enumMatch = modelContext.enumerations?.find(
708
+ (e) => e.path === prop.type,
709
+ );
710
+ if (enumMatch) {
711
+ entry.probablePropertyValues = enumMatch.values;
712
+ entry.matchType = ['enumeration'];
713
+ } else if (isPrimitiveType(prop.type)) {
714
+ entry.matchType = [prop.type];
715
+ } else {
716
+ entry.matchType = ['entity'];
717
+ }
718
+ return entry;
719
+ });
720
+ }
721
+
722
+ function buildRelatedEntityProperties(
723
+ relatedEntities: string[],
724
+ entityMap: Map<string, LegendAIModelEntity>,
725
+ ): LegendAIBusinessContextProperty[] {
726
+ const properties: LegendAIBusinessContextProperty[] = [];
727
+ let relatedPropCount = 0;
728
+ for (const relPath of relatedEntities) {
729
+ if (relatedPropCount >= MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES) {
730
+ break;
731
+ }
732
+ const relEntity = entityMap.get(relPath);
733
+ if (!relEntity) {
734
+ continue;
735
+ }
736
+ for (const prop of relEntity.properties) {
737
+ if (relatedPropCount >= MAX_BUSINESS_CONTEXT_RELATED_PROPERTIES) {
738
+ break;
739
+ }
740
+ if (isPrimitiveType(prop.type)) {
741
+ properties.push({
742
+ propertyName: `${relEntity.name}.${prop.name}`,
743
+ matchType: [prop.type],
744
+ });
745
+ relatedPropCount++;
746
+ }
747
+ }
748
+ }
749
+ return properties;
750
+ }
751
+
752
+ function buildRootEntityNlHints(
753
+ rootEntity: string,
754
+ rootEntityObj: LegendAIModelEntity | undefined,
755
+ modelContext: LegendAIModelContext,
756
+ ): LegendAIAdditionalNlModelContext[] {
757
+ if (!rootEntityObj) {
758
+ return [];
759
+ }
760
+ const entries: LegendAIAdditionalNlModelContext[] = [];
761
+ if (rootEntityObj.description) {
762
+ entries.push({
763
+ id: rootEntity,
764
+ description: rootEntityObj.description,
765
+ category: 'root_entity',
766
+ });
767
+ }
768
+ if (rootEntityObj.isRootMapped) {
769
+ entries.push({
770
+ id: rootEntity,
771
+ description: `${rootEntityObj.name} is a directly mapped root entity — it can be queried directly without navigating from another entity`,
772
+ category: 'mapping_hint',
773
+ });
774
+ }
775
+ if (rootEntityObj.isQueryable) {
776
+ const matchingExec = modelContext.executables?.find(
777
+ (ex) => ex.rootEntityPath === rootEntity,
778
+ );
779
+ const execHint = matchingExec ? ` (service: "${matchingExec.title}")` : '';
780
+ entries.push({
781
+ id: rootEntity,
782
+ description: `${rootEntityObj.name} is queryable via a service executable${execHint} — this entity can be queried with .all()`,
783
+ category: 'queryable_hint',
784
+ });
785
+ }
786
+ return entries;
787
+ }
788
+
789
+ function buildAssociationNlHints(
790
+ rootEntity: string,
791
+ relatedEntities: string[],
792
+ modelContext: LegendAIModelContext,
793
+ ): LegendAIAdditionalNlModelContext[] {
794
+ const involvedPaths = new Set([rootEntity, ...relatedEntities]);
795
+ return modelContext.associations
796
+ .filter(
797
+ (assoc) =>
798
+ involvedPaths.has(assoc.leftEntity) ||
799
+ involvedPaths.has(assoc.rightEntity),
800
+ )
801
+ .map((assoc) => ({
802
+ id: assoc.name,
803
+ 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)}`,
804
+ category: 'association' as const,
805
+ }));
806
+ }
807
+
808
+ function buildExecutableNlHints(
809
+ rootEntity: string,
810
+ rootEntityObj: LegendAIModelEntity | undefined,
811
+ modelContext: LegendAIModelContext,
812
+ ): LegendAIAdditionalNlModelContext[] {
813
+ if (!modelContext.executables) {
814
+ return [];
815
+ }
816
+ const entries: LegendAIAdditionalNlModelContext[] = [];
817
+ const matchingExecs = modelContext.executables.filter(
818
+ (ex) => ex.rootEntityPath === rootEntity,
819
+ );
820
+ for (const exec of matchingExecs) {
821
+ if (exec.queryTemplate) {
822
+ entries.push({
823
+ id: `query_template:${exec.title}`,
824
+ description: `Example Pure query for ${rootEntityObj?.name ?? rootEntity}: ${exec.queryTemplate}`,
825
+ category: 'query_template',
826
+ });
827
+ }
828
+ if (exec.requiredParameters && exec.requiredParameters.length > 0) {
829
+ const paramList = exec.requiredParameters
830
+ .map((p) => `${p.name} (${p.type})`)
831
+ .join(', ');
832
+ entries.push({
833
+ id: `required_params:${exec.title}`,
834
+ description: `This entity requires the following filter parameters: ${paramList}. The generated query MUST include filters for these parameters.`,
835
+ category: 'required_parameters',
836
+ });
837
+ }
838
+ if (exec.columnPropertyMappings && exec.columnPropertyMappings.length > 0) {
839
+ entries.push({
840
+ id: `column_mappings:${exec.title}`,
841
+ description: `Property name mappings (human-readable → model property): ${exec.columnPropertyMappings
842
+ .slice(0, 20)
843
+ .map((m) => `"${m.columnName}" → ${m.propertyPath}`)
844
+ .join(', ')}`,
845
+ category: 'column_mappings',
846
+ });
847
+ }
848
+ }
849
+ const execSummary = modelContext.executables
850
+ .map((ex) => {
851
+ const entityName = extractElementNameFromPath(ex.rootEntityPath);
852
+ const paramHint =
853
+ ex.requiredParameters && ex.requiredParameters.length > 0
854
+ ? ` (requires: ${ex.requiredParameters.map((p) => p.name).join(', ')})`
855
+ : '';
856
+ return `"${ex.title}" → ${entityName}${paramHint}`;
857
+ })
858
+ .join('; ');
859
+ entries.push({
860
+ id: 'executable_summary',
861
+ description: `Available service executables: ${execSummary}`,
862
+ category: 'executable_summary',
863
+ });
864
+ return entries;
865
+ }
866
+
867
+ function findCrossClassPropertyHints(
868
+ entity: LegendAIModelEntity,
869
+ rootEntityObj: LegendAIModelEntity,
870
+ rootPropNames: Set<string>,
871
+ questionTokens: Set<string>,
872
+ questionLower: string,
873
+ ): string[] {
874
+ const hints: string[] = [];
875
+ for (const prop of entity.properties) {
876
+ const propTokens = splitIdentifierTokens(prop.name).filter(
877
+ (t) => t.length > 2,
878
+ );
879
+ const matchScore = propTokens.filter(
880
+ (t) =>
881
+ questionTokens.has(t) || (t.length >= 4 && questionLower.includes(t)),
882
+ ).length;
883
+ if (matchScore < 1 || (matchScore < 2 && propTokens.length > 2)) {
884
+ continue;
885
+ }
886
+ const rootHasSimilar = rootEntityObj.properties.some((rp) => {
887
+ const rpTokens = splitIdentifierTokens(rp.name).filter(
888
+ (t) => t.length > 2,
889
+ );
890
+ return (
891
+ rpTokens.filter((t) => propTokens.includes(t)).length >=
892
+ Math.max(1, propTokens.length - 1)
893
+ );
894
+ });
895
+ if (!rootHasSimilar && !rootPropNames.has(prop.name.toLowerCase())) {
896
+ hints.push(
897
+ `"${prop.name}" exists on ${entity.name} but NOT on ${rootEntityObj.name}`,
898
+ );
899
+ }
900
+ }
901
+ return hints;
902
+ }
903
+
904
+ function buildCrossClassNlHints(
905
+ question: string,
906
+ rootEntity: string,
907
+ rootEntityObj: LegendAIModelEntity,
908
+ modelContext: LegendAIModelContext,
909
+ ): LegendAIAdditionalNlModelContext[] {
910
+ const questionTokens = new Set(tokenizeText(question));
911
+ const rootPropNames = new Set(
912
+ rootEntityObj.properties.map((p) => p.name.toLowerCase()),
913
+ );
914
+ const questionLower = question.toLowerCase();
915
+ const crossClassHints: string[] = [];
916
+ for (const entity of modelContext.entities) {
917
+ if (entity.path === rootEntity) {
918
+ continue;
919
+ }
920
+ crossClassHints.push(
921
+ ...findCrossClassPropertyHints(
922
+ entity,
923
+ rootEntityObj,
924
+ rootPropNames,
925
+ questionTokens,
926
+ questionLower,
927
+ ),
928
+ );
929
+ }
930
+ if (crossClassHints.length === 0) {
931
+ return [];
932
+ }
933
+ return [
934
+ {
935
+ id: 'cross_class_property_warning',
936
+ 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}.`,
937
+ category: 'cross_class_warning',
938
+ },
939
+ ];
940
+ }
941
+
942
+ function buildNlContextEntries(
943
+ question: string,
944
+ rootEntity: string,
945
+ rootEntityObj: LegendAIModelEntity | undefined,
946
+ relatedEntities: string[],
947
+ modelContext: LegendAIModelContext,
948
+ entityMap: Map<string, LegendAIModelEntity>,
949
+ ): LegendAIAdditionalNlModelContext[] {
950
+ const entries: LegendAIAdditionalNlModelContext[] = [
951
+ ...buildRootEntityNlHints(rootEntity, rootEntityObj, modelContext),
952
+ ];
953
+ if (modelContext.dataspaceDescription) {
954
+ const shortDesc = modelContext.dataspaceDescription
955
+ .split('\n')
956
+ .filter((l) => !l.startsWith('#') && l.trim().length > 0)
957
+ .slice(0, 3)
958
+ .join(' ')
959
+ .slice(0, 500);
960
+ if (shortDesc.length > 0) {
961
+ entries.push({
962
+ id: 'dataspace',
963
+ description: shortDesc,
964
+ category: 'product_context',
965
+ });
966
+ }
967
+ }
968
+ for (const relPath of relatedEntities) {
969
+ const relEntity = entityMap.get(relPath);
970
+ if (relEntity?.description) {
971
+ entries.push({
972
+ id: relPath,
973
+ description: relEntity.description,
974
+ category: 'related_entity',
975
+ });
976
+ }
977
+ }
978
+ entries.push(
979
+ ...buildAssociationNlHints(rootEntity, relatedEntities, modelContext),
980
+ ...buildExecutableNlHints(rootEntity, rootEntityObj, modelContext),
981
+ );
982
+ if (rootEntityObj) {
983
+ entries.push(
984
+ ...buildCrossClassNlHints(
985
+ question,
986
+ rootEntity,
987
+ rootEntityObj,
988
+ modelContext,
989
+ ),
990
+ );
991
+ }
992
+ return entries;
993
+ }
994
+
995
+ export function buildEnrichedBusinessContext(
996
+ question: string,
997
+ rootEntity: string,
998
+ relatedEntities: string[],
999
+ modelContext: LegendAIModelContext,
1000
+ ): LegendAIEnrichedBusinessContext {
1001
+ const entityMap = new Map(modelContext.entities.map((e) => [e.path, e]));
1002
+ const rootEntityObj = entityMap.get(rootEntity);
1003
+ const properties = [
1004
+ ...buildRootEntityProperties(rootEntityObj, modelContext),
1005
+ ...buildRelatedEntityProperties(relatedEntities, entityMap),
1006
+ ];
1007
+ const additionalNlModelContext = buildNlContextEntries(
1008
+ question,
1009
+ rootEntity,
1010
+ rootEntityObj,
1011
+ relatedEntities,
1012
+ modelContext,
1013
+ entityMap,
1014
+ );
1015
+ const context: LegendAIEnrichedBusinessContext = {
1016
+ naturalLanguageQuery: question,
1017
+ };
1018
+ if (properties.length > 0 || additionalNlModelContext.length > 0) {
1019
+ context.businessContextMatch = {};
1020
+ if (properties.length > 0) {
1021
+ context.businessContextMatch.properties = properties;
1022
+ }
1023
+ if (additionalNlModelContext.length > 0) {
1024
+ context.businessContextMatch.additionalNlModelContext =
1025
+ additionalNlModelContext;
1026
+ }
1027
+ }
1028
+ return context;
1029
+ }
1030
+
1031
+ const PRIMITIVE_TYPE_NAMES: ReadonlySet<string> = new Set([
1032
+ PRIMITIVE_TYPE.STRING,
1033
+ PRIMITIVE_TYPE.INTEGER,
1034
+ PRIMITIVE_TYPE.FLOAT,
1035
+ PRIMITIVE_TYPE.DECIMAL,
1036
+ PRIMITIVE_TYPE.BOOLEAN,
1037
+ PRIMITIVE_TYPE.DATE,
1038
+ PRIMITIVE_TYPE.STRICTDATE,
1039
+ PRIMITIVE_TYPE.DATETIME,
1040
+ PRIMITIVE_TYPE.NUMBER,
1041
+ ]);
1042
+
1043
+ function isPrimitiveType(type: string): boolean {
1044
+ return PRIMITIVE_TYPE_NAMES.has(type);
1045
+ }
1046
+
1047
+ // Acronym boundary regex (bounded repetition to prevent catastrophic backtracking
1048
+ // on adversarial inputs). Inserts a space before the last uppercase letter of an
1049
+ // acronym when followed by a lowercase letter — e.g. "XMLParser" → "XML Parser".
1050
+ const ACRONYM_BOUNDARY_PATTERN = /(?<acr>[A-Z]{1,32})(?<head>[A-Z][a-z])/g;
1051
+
1052
+ /**
1053
+ * Splits a camelCase, PascalCase, or snake_case identifier into lowercase
1054
+ * tokens for fuzzy matching. e.g. "taxIDTypeCD" → ["tax", "id", "type", "cd"],
1055
+ * "FISCAL_YEAR_START" → ["fiscal", "year", "start"].
1056
+ */
1057
+ export function splitIdentifierTokens(name: string): string[] {
1058
+ return name
1059
+ .replaceAll(/(?<lower>[a-z])(?<upper>[A-Z])/g, '$<lower> $<upper>')
1060
+ .replaceAll(/[_\-./]+/g, ' ')
1061
+ .replaceAll(ACRONYM_BOUNDARY_PATTERN, '$<acr> $<head>')
1062
+ .toLowerCase()
1063
+ .split(/\s+/)
1064
+ .filter((t) => t.length > 1);
1065
+ }
1066
+
1067
+ const TEXT_TOKEN_SPLIT = /[\s,.:;!?'"()\-/]+/;
1068
+ const DEFAULT_MIN_TOKEN_LENGTH = 3;
1069
+
1070
+ /**
1071
+ * Canonical natural-language tokenizer for the Legend AI pipeline. Splits on
1072
+ * standard punctuation/whitespace, lowercases, drops short tokens, and
1073
+ * optionally filters a stopword set. Used by BM25 indexing, coverage
1074
+ * checks, and cross-class hint matching to keep tokenization consistent.
1075
+ */
1076
+ export function tokenizeText(
1077
+ text: string,
1078
+ options?: {
1079
+ minLength?: number;
1080
+ stopwords?: ReadonlySet<string>;
1081
+ },
1082
+ ): string[] {
1083
+ const minLength = options?.minLength ?? DEFAULT_MIN_TOKEN_LENGTH;
1084
+ const stopwords = options?.stopwords;
1085
+ return text
1086
+ .toLowerCase()
1087
+ .split(TEXT_TOKEN_SPLIT)
1088
+ .filter((t) => t.length >= minLength && !(stopwords?.has(t) ?? false));
1089
+ }
1090
+
1091
+ // ────────────────────────────────────────────────────────────────────────────
1092
+ // Association-aware alternate root selection (Phase 3)
1093
+ // ────────────────────────────────────────────────────────────────────────────
1094
+
1095
+ /**
1096
+ * Picks the best alternate root entity when a query returns zero results.
1097
+ * Ranks candidates by their association connectivity — entities with more
1098
+ * connections to other model entities are more likely to have queryable data.
1099
+ * A direct association with the failed root gets a bonus so closely-related
1100
+ * entities are preferred.
1101
+ */
1102
+ export function findBestAlternateRoot(
1103
+ failedRoot: string,
1104
+ relatedEntities: string[],
1105
+ modelContext: LegendAIModelContext,
1106
+ ): string | undefined {
1107
+ if (relatedEntities.length === 0) {
1108
+ return undefined;
1109
+ }
1110
+ if (relatedEntities.length === 1 || modelContext.associations.length === 0) {
1111
+ return relatedEntities[0];
1112
+ }
1113
+
1114
+ const scores = relatedEntities.map((entity) => {
1115
+ let score = 0;
1116
+ for (const assoc of modelContext.associations) {
1117
+ const isInvolved =
1118
+ assoc.leftEntity === entity || assoc.rightEntity === entity;
1119
+ if (isInvolved) {
1120
+ score += 1;
1121
+ if (
1122
+ assoc.leftEntity === failedRoot ||
1123
+ assoc.rightEntity === failedRoot
1124
+ ) {
1125
+ score += 2;
1126
+ }
1127
+ }
1128
+ }
1129
+ return { entity, score };
1130
+ });
1131
+
1132
+ scores.sort((a, b) => b.score - a.score);
1133
+ return scores[0]?.entity ?? relatedEntities[0];
1134
+ }
1135
+
1136
+ // ────────────────────────────────────────────────────────────────────────────
1137
+ // Semantic property-doc index for RAG-like matching
1138
+ // ────────────────────────────────────────────────────────────────────────────
1139
+
1140
+ function addTokensToIndex(
1141
+ index: Map<string, Set<string>>,
1142
+ tokens: string[],
1143
+ entityPath: string,
1144
+ ): void {
1145
+ for (const token of tokens) {
1146
+ let set = index.get(token);
1147
+ if (!set) {
1148
+ set = new Set();
1149
+ index.set(token, set);
1150
+ }
1151
+ set.add(entityPath);
1152
+ }
1153
+ }
1154
+
1155
+ export function buildSemanticPropertyIndex(
1156
+ modelContext: LegendAIModelContext,
1157
+ ): Map<string, Set<string>> {
1158
+ const index = new Map<string, Set<string>>();
1159
+ for (const entity of modelContext.entities) {
1160
+ 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);
1166
+ addTokensToIndex(index, nameTokens, entity.path);
1167
+ }
1168
+ 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);
1174
+ }
1175
+ }
1176
+ return index;
1177
+ }
1178
+
1179
+ // ────────────────────────────────────────────────────────────────────────────
1180
+ // Deterministic entity resolution (no LLM required)
1181
+ // ────────────────────────────────────────────────────────────────────────────
1182
+
1183
+ /**
1184
+ * Resolves root and related entities using keyword matching against the model
1185
+ * context. This works without any LLM call and is used as the final fallback
1186
+ * when both LLM-based resolution and marketplace search are unavailable.
1187
+ *
1188
+ * Scoring:
1189
+ * - entity name match (+5)
1190
+ * - property name match (+2)
1191
+ * - isQueryable bonus (+10) — entity is directly queried by a service
1192
+ * - isRootMapped bonus (+3)
1193
+ * - description keyword match (+1)
1194
+ * - executable title/description match (+4)
1195
+ * - semantic property-doc match (+1 per token hit)
1196
+ *
1197
+ * When queryable entities exist, only they are eligible as root.
1198
+ */
1199
+ function pickFallbackEntity(
1200
+ queryableEntities: LegendAIModelEntity[],
1201
+ entities: LegendAIModelEntity[],
1202
+ ): { rootEntity: string; relatedEntities: string[] } | undefined {
1203
+ const preferred =
1204
+ queryableEntities[0] ?? entities.find((e) => e.isRootMapped) ?? entities[0];
1205
+ return preferred
1206
+ ? { rootEntity: preferred.path, relatedEntities: [] }
1207
+ : undefined;
1208
+ }
1209
+
1210
+ function buildExecutableByRootIndex(
1211
+ modelContext: LegendAIModelContext,
1212
+ ): Map<string, string[]> {
1213
+ const index = new Map<string, string[]>();
1214
+ for (const exec of modelContext.executables ?? []) {
1215
+ let words = index.get(exec.rootEntityPath);
1216
+ if (!words) {
1217
+ words = [];
1218
+ index.set(exec.rootEntityPath, words);
1219
+ }
1220
+ words.push(
1221
+ ...`${exec.title} ${exec.description ?? ''}`
1222
+ .toLowerCase()
1223
+ .split(/[\s,.;:!?'"()\-/]+/)
1224
+ .filter((t) => t.length > 2),
1225
+ );
1226
+ }
1227
+ return index;
1228
+ }
1229
+
1230
+ function scoreEntityByProperties(
1231
+ entity: LegendAIModelEntity,
1232
+ tokens: string[],
1233
+ ): number {
1234
+ let score = 0;
1235
+ for (const prop of entity.properties) {
1236
+ const propLower = prop.name.toLowerCase();
1237
+ for (const token of tokens) {
1238
+ if (propLower === token || propLower.includes(token)) {
1239
+ score += 2;
1240
+ break;
1241
+ }
1242
+ }
1243
+ }
1244
+ return score;
1245
+ }
1246
+
1247
+ function scoreEntity(
1248
+ entity: LegendAIModelEntity,
1249
+ tokens: string[],
1250
+ semanticIndex: Map<string, Set<string>>,
1251
+ executableByRoot: Map<string, string[]>,
1252
+ ): number {
1253
+ const nameLower = entity.name.toLowerCase();
1254
+ let score =
1255
+ tokens.filter((t) => nameLower === t || nameLower.includes(t)).length * 5;
1256
+ score += scoreEntityByProperties(entity, tokens);
1257
+ if (entity.isQueryable) {
1258
+ score += 10;
1259
+ }
1260
+ if (entity.isRootMapped) {
1261
+ score += 3;
1262
+ }
1263
+ if (entity.description) {
1264
+ const descLower = entity.description.toLowerCase();
1265
+ score += tokens.filter((t) => descLower.includes(t)).length;
1266
+ }
1267
+ const execWords = executableByRoot.get(entity.path);
1268
+ if (execWords) {
1269
+ score +=
1270
+ tokens.filter((t) => execWords.some((w) => w === t || w.includes(t)))
1271
+ .length * 4;
1272
+ }
1273
+ score += tokens.filter((t) => semanticIndex.get(t)?.has(entity.path)).length;
1274
+ return score;
1275
+ }
1276
+
1277
+ export function resolveEntitiesDeterministic(
1278
+ question: string,
1279
+ modelContext: LegendAIModelContext,
1280
+ ): { rootEntity: string; relatedEntities: string[] } | undefined {
1281
+ if (modelContext.entities.length === 0) {
1282
+ return undefined;
1283
+ }
1284
+ if (modelContext.entities.length === 1) {
1285
+ const singleEntity = modelContext.entities[0];
1286
+ if (singleEntity) {
1287
+ return { rootEntity: singleEntity.path, relatedEntities: [] };
1288
+ }
1289
+ }
1290
+
1291
+ 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
+
1300
+ if (tokens.length === 0) {
1301
+ return pickFallbackEntity(queryableEntities, modelContext.entities);
1302
+ }
1303
+
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);
1310
+
1311
+ const rootCandidates =
1312
+ queryableEntities.length > 0
1313
+ ? scored.filter((s) => s.entity.isQueryable)
1314
+ : scored;
1315
+
1316
+ const winner = rootCandidates[0];
1317
+ if (!winner || winner.score === 0) {
1318
+ return pickFallbackEntity(queryableEntities, modelContext.entities);
1319
+ }
1320
+
1321
+ const rootPath = winner.entity.path;
1322
+ const relatedSet = new Set<string>();
1323
+ for (const assoc of modelContext.associations) {
1324
+ if (assoc.leftEntity === rootPath) {
1325
+ relatedSet.add(assoc.rightEntity);
1326
+ } else if (assoc.rightEntity === rootPath) {
1327
+ relatedSet.add(assoc.leftEntity);
1328
+ }
1329
+ }
1330
+ for (const s of scored.slice(1)) {
1331
+ if (s.score > 0 && relatedSet.size < 5) {
1332
+ relatedSet.add(s.entity.path);
1333
+ }
1334
+ }
1335
+
1336
+ return {
1337
+ rootEntity: rootPath,
1338
+ relatedEntities: Array.from(relatedSet).slice(0, 5),
1339
+ };
1340
+ }
1341
+
1342
+ // ────────────────────────────────────────────────────────────────────────────
1343
+ // Model context enrichment for SQL / metadata prompts
1344
+ // ────────────────────────────────────────────────────────────────────────────
1345
+
1346
+ const MAX_ENRICHMENT_ENTITIES = 8;
1347
+ const MAX_ENRICHMENT_PROPS_PER_ENTITY = 15;
1348
+ const MAX_ENRICHMENT_ENUMS = 10;
1349
+ const MAX_ENRICHMENT_ENUM_VALUES = 20;
1350
+ const MAX_ENRICHMENT_ASSOCIATIONS = 10;
1351
+
1352
+ /**
1353
+ * Maps each TDS service to the model entity whose properties best match
1354
+ * the service's column names. Uses a simple overlap-scoring approach:
1355
+ * for each (service, entity) pair, count how many service column names
1356
+ * match entity property names (case-insensitive). The entity with the
1357
+ * highest overlap wins, provided it exceeds a minimum threshold.
1358
+ *
1359
+ * Returns a Map<serviceTitle, entityPath>.
1360
+ */
1361
+ function inferServiceEntityMapping(
1362
+ services: TDSServiceSchema[],
1363
+ modelContext: LegendAIModelContext,
1364
+ ): Map<string, string> {
1365
+ const result = new Map<string, string>();
1366
+ const MIN_OVERLAP = 2; // require at least 2 matching columns
1367
+
1368
+ const entityPropSets = modelContext.entities.map((entity) => ({
1369
+ path: entity.path,
1370
+ props: new Set(entity.properties.map((p) => p.name.toLowerCase())),
1371
+ }));
1372
+
1373
+ for (const svc of services) {
1374
+ const colNames = svc.columns.map((c) => c.name.toLowerCase());
1375
+ let bestEntity = '';
1376
+ let bestScore = 0;
1377
+
1378
+ for (const { path, props } of entityPropSets) {
1379
+ const overlap = colNames.filter((c) => props.has(c)).length;
1380
+ if (overlap > bestScore) {
1381
+ bestScore = overlap;
1382
+ bestEntity = path;
1383
+ }
1384
+ }
1385
+
1386
+ if (bestScore >= MIN_OVERLAP && bestEntity) {
1387
+ result.set(svc.title, bestEntity);
1388
+ }
1389
+ }
1390
+
1391
+ return result;
1392
+ }
1393
+
1394
+ function buildProductDescriptionSection(
1395
+ modelContext: LegendAIModelContext,
1396
+ ): string | undefined {
1397
+ if (!modelContext.dataspaceDescription) {
1398
+ return undefined;
1399
+ }
1400
+ const shortDesc = modelContext.dataspaceDescription
1401
+ .split('\n')
1402
+ .filter((l) => !l.startsWith('#') && l.trim().length > 0)
1403
+ .slice(0, 3)
1404
+ .join(' ')
1405
+ .slice(0, 500);
1406
+ return shortDesc.length > 0
1407
+ ? `## Data Model Overview\n${shortDesc}`
1408
+ : undefined;
1409
+ }
1410
+
1411
+ function buildEntitySection(
1412
+ modelContext: LegendAIModelContext,
1413
+ ): string | undefined {
1414
+ if (modelContext.entities.length === 0) {
1415
+ return undefined;
1416
+ }
1417
+ const sorted = [...modelContext.entities].sort((a, b) => {
1418
+ const aScore = (a.isQueryable ? 10 : 0) + (a.isRootMapped ? 3 : 0);
1419
+ const bScore = (b.isQueryable ? 10 : 0) + (b.isRootMapped ? 3 : 0);
1420
+ return bScore - aScore;
1421
+ });
1422
+ const lines: string[] = ['## Model Entities'];
1423
+ for (const entity of sorted.slice(0, MAX_ENRICHMENT_ENTITIES)) {
1424
+ const tags: string[] = [];
1425
+ if (entity.isQueryable) {
1426
+ tags.push('QUERYABLE');
1427
+ }
1428
+ if (entity.isRootMapped) {
1429
+ tags.push('ROOT_MAPPED');
1430
+ }
1431
+ const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
1432
+ lines.push(`\n### ${entity.name}${tagStr}`);
1433
+ if (entity.description) {
1434
+ lines.push(entity.description.slice(0, 300));
1435
+ }
1436
+ const propLines = entity.properties
1437
+ .slice(0, MAX_ENRICHMENT_PROPS_PER_ENTITY)
1438
+ .map((p) => `- ${p.name}: ${p.type}${p.isCollection ? ' (many)' : ''}`);
1439
+ if (propLines.length > 0) {
1440
+ lines.push('Properties:', ...propLines);
1441
+ }
1442
+ }
1443
+ if (modelContext.entities.length > MAX_ENRICHMENT_ENTITIES) {
1444
+ lines.push(
1445
+ `\n(${modelContext.entities.length - MAX_ENRICHMENT_ENTITIES} additional entities omitted)`,
1446
+ );
1447
+ }
1448
+ return lines.join('\n');
1449
+ }
1450
+
1451
+ function buildEnumerationSection(
1452
+ modelContext: LegendAIModelContext,
1453
+ ): string | undefined {
1454
+ if (!modelContext.enumerations?.length) {
1455
+ return undefined;
1456
+ }
1457
+ const lines = ['## Enumerations (valid filter values)'];
1458
+ for (const en of modelContext.enumerations.slice(0, MAX_ENRICHMENT_ENUMS)) {
1459
+ const vals = en.values.slice(0, MAX_ENRICHMENT_ENUM_VALUES).join(', ');
1460
+ const suffix =
1461
+ en.values.length > MAX_ENRICHMENT_ENUM_VALUES
1462
+ ? `, ... (${en.values.length} total)`
1463
+ : '';
1464
+ lines.push(`- ${extractElementNameFromPath(en.path)}: ${vals}${suffix}`);
1465
+ }
1466
+ return lines.join('\n');
1467
+ }
1468
+
1469
+ function buildAssociationSection(
1470
+ modelContext: LegendAIModelContext,
1471
+ ): string | undefined {
1472
+ if (modelContext.associations.length === 0) {
1473
+ return undefined;
1474
+ }
1475
+ const lines = ['## Entity Relationships'];
1476
+ for (const assoc of modelContext.associations.slice(
1477
+ 0,
1478
+ MAX_ENRICHMENT_ASSOCIATIONS,
1479
+ )) {
1480
+ const l = extractElementNameFromPath(assoc.leftEntity);
1481
+ const r = extractElementNameFromPath(assoc.rightEntity);
1482
+ lines.push(
1483
+ `- ${l}.${assoc.leftProperty} → ${r}, ${r}.${assoc.rightProperty} → ${l}`,
1484
+ );
1485
+ }
1486
+ return lines.join('\n');
1487
+ }
1488
+
1489
+ function buildExecutableSection(
1490
+ modelContext: LegendAIModelContext,
1491
+ ): string | undefined {
1492
+ if (!modelContext.executables?.length) {
1493
+ return undefined;
1494
+ }
1495
+ const lines = ['## Available Executables'];
1496
+ for (const exec of modelContext.executables) {
1497
+ lines.push(
1498
+ `\n- "${exec.title}" → ${extractElementNameFromPath(exec.rootEntityPath)}`,
1499
+ );
1500
+ if (exec.description) {
1501
+ lines.push(` ${exec.description.slice(0, 200)}`);
1502
+ }
1503
+ if (exec.requiredParameters && exec.requiredParameters.length > 0) {
1504
+ const paramList = exec.requiredParameters
1505
+ .map((p) => `${p.name} (${p.type})`)
1506
+ .join(', ');
1507
+ lines.push(` Required parameters: ${paramList}`);
1508
+ }
1509
+ }
1510
+ return lines.join('\n');
1511
+ }
1512
+
1513
+ function buildPropToEnumPathMap(
1514
+ modelContext: LegendAIModelContext,
1515
+ enumPathMap: Map<string, string[]>,
1516
+ ): Map<string, string> {
1517
+ const propToEnumPath = new Map<string, string>();
1518
+ for (const entity of modelContext.entities) {
1519
+ for (const prop of entity.properties) {
1520
+ if (enumPathMap.has(prop.type)) {
1521
+ propToEnumPath.set(prop.name.toLowerCase(), prop.type);
1522
+ }
1523
+ }
1524
+ }
1525
+ return propToEnumPath;
1526
+ }
1527
+
1528
+ function buildColumnEnumLine(
1529
+ col: TDSColumnSchema,
1530
+ propToEnumPath: Map<string, string>,
1531
+ enumPathMap: Map<string, string[]>,
1532
+ ): string | undefined {
1533
+ const enumPath = propToEnumPath.get(col.name.toLowerCase());
1534
+ if (!enumPath) {
1535
+ return undefined;
1536
+ }
1537
+ const enumValues = enumPathMap.get(enumPath);
1538
+ if (!enumValues?.length) {
1539
+ return undefined;
1540
+ }
1541
+ const vals = enumValues.slice(0, MAX_ENRICHMENT_ENUM_VALUES).join(', ');
1542
+ const suffix =
1543
+ enumValues.length > MAX_ENRICHMENT_ENUM_VALUES
1544
+ ? `, ... (${enumValues.length} total)`
1545
+ : '';
1546
+ return `- Column "${col.name}" accepts: ${vals}${suffix}`;
1547
+ }
1548
+
1549
+ function buildColumnEnumMappingSection(
1550
+ modelContext: LegendAIModelContext,
1551
+ services: TDSServiceSchema[] | undefined,
1552
+ ): string | undefined {
1553
+ if (!services?.length || !modelContext.enumerations?.length) {
1554
+ return undefined;
1555
+ }
1556
+ const enumPathMap = new Map(
1557
+ modelContext.enumerations.map((en) => [en.path, en.values]),
1558
+ );
1559
+ const propToEnumPath = buildPropToEnumPathMap(modelContext, enumPathMap);
1560
+ if (propToEnumPath.size === 0) {
1561
+ return undefined;
1562
+ }
1563
+ const seen = new Set<string>();
1564
+ const mappingLines: string[] = [];
1565
+ for (const svc of services) {
1566
+ for (const col of svc.columns) {
1567
+ const colLower = col.name.toLowerCase();
1568
+ if (seen.has(colLower)) {
1569
+ continue;
1570
+ }
1571
+ const line = buildColumnEnumLine(col, propToEnumPath, enumPathMap);
1572
+ if (line) {
1573
+ mappingLines.push(line);
1574
+ seen.add(colLower);
1575
+ }
1576
+ }
1577
+ }
1578
+ if (mappingLines.length === 0) {
1579
+ return undefined;
1580
+ }
1581
+ return [
1582
+ '## Column Filter Value Mappings',
1583
+ 'When filtering on these columns, use ONLY the exact values listed below:',
1584
+ ...mappingLines,
1585
+ ].join('\n');
1586
+ }
1587
+
1588
+ function buildPropTypeDescMap(
1589
+ modelContext: LegendAIModelContext,
1590
+ ): Map<string, string> {
1591
+ const map = new Map<string, string>();
1592
+ for (const entity of modelContext.entities) {
1593
+ for (const prop of entity.properties) {
1594
+ if (!map.has(prop.name.toLowerCase())) {
1595
+ map.set(
1596
+ prop.name.toLowerCase(),
1597
+ isPrimitiveType(prop.type)
1598
+ ? prop.type
1599
+ : extractElementNameFromPath(prop.type),
1600
+ );
1601
+ }
1602
+ }
1603
+ }
1604
+ return map;
1605
+ }
1606
+
1607
+ function buildColumnDescriptionSection(
1608
+ modelContext: LegendAIModelContext,
1609
+ services: TDSServiceSchema[] | undefined,
1610
+ ): string | undefined {
1611
+ if (!services?.length || !modelContext.entities.length) {
1612
+ return undefined;
1613
+ }
1614
+ const propDescMap = buildPropTypeDescMap(modelContext);
1615
+ const seen = new Set<string>();
1616
+ const undocumented: string[] = [];
1617
+ for (const svc of services) {
1618
+ for (const col of svc.columns) {
1619
+ const colLower = col.name.toLowerCase();
1620
+ if (col.documentation || seen.has(colLower)) {
1621
+ continue;
1622
+ }
1623
+ const modelType = propDescMap.get(colLower);
1624
+ if (modelType && modelType !== (col.type ?? '')) {
1625
+ undocumented.push(
1626
+ `- "${col.name}" corresponds to model property type ${modelType}`,
1627
+ );
1628
+ seen.add(colLower);
1629
+ }
1630
+ }
1631
+ }
1632
+ if (undocumented.length === 0) {
1633
+ return undefined;
1634
+ }
1635
+ return [
1636
+ '## Column-to-Model Property Hints',
1637
+ ...undocumented.slice(0, 20),
1638
+ ].join('\n');
1639
+ }
1640
+
1641
+ function buildServicePairHint(
1642
+ svcA: string,
1643
+ entityA: string,
1644
+ svcB: string,
1645
+ entityB: string,
1646
+ modelContext: LegendAIModelContext,
1647
+ services: TDSServiceSchema[],
1648
+ ): string | undefined {
1649
+ if (entityA === entityB) {
1650
+ return `\n- **${svcA}** and **${svcB}** query the SAME entity — JOIN on any shared identifier column.`;
1651
+ }
1652
+ const assoc = modelContext.associations.find(
1653
+ (a) =>
1654
+ (a.leftEntity === entityA && a.rightEntity === entityB) ||
1655
+ (a.leftEntity === entityB && a.rightEntity === entityA),
1656
+ );
1657
+ if (!assoc) {
1658
+ return undefined;
1659
+ }
1660
+ const leftName = extractElementNameFromPath(assoc.leftEntity);
1661
+ 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);
1671
+ const joinKeyHint =
1672
+ shared.length > 0
1673
+ ? `JOIN ON shared columns: ${shared.join(', ')}`
1674
+ : `No shared columns by name — match on equivalent business keys (e.g. ${leftName} ID ↔ ${rightName} foreign key)`;
1675
+ return `\n- **${svcA}** ↔ **${svcB}**: Related via ${leftName}.${assoc.leftProperty} → ${rightName}. ${joinKeyHint}`;
1676
+ }
1677
+
1678
+ function buildServicePairHints(
1679
+ serviceEntityMap: Map<string, string>,
1680
+ modelContext: LegendAIModelContext,
1681
+ services: TDSServiceSchema[],
1682
+ ): string[] {
1683
+ const svcEntries = Array.from(serviceEntityMap.entries());
1684
+ const pairHints: string[] = [];
1685
+ for (let i = 0; i < svcEntries.length; i++) {
1686
+ for (let j = i + 1; j < svcEntries.length; j++) {
1687
+ const entryA = svcEntries[i];
1688
+ const entryB = svcEntries[j];
1689
+ if (!entryA || !entryB) {
1690
+ continue;
1691
+ }
1692
+ const [svcA, entityA] = entryA;
1693
+ const [svcB, entityB] = entryB;
1694
+ const hint = buildServicePairHint(
1695
+ svcA,
1696
+ entityA,
1697
+ svcB,
1698
+ entityB,
1699
+ modelContext,
1700
+ services,
1701
+ );
1702
+ if (hint) {
1703
+ pairHints.push(hint);
1704
+ }
1705
+ }
1706
+ }
1707
+ return pairHints;
1708
+ }
1709
+
1710
+ function buildServiceJoinSection(
1711
+ modelContext: LegendAIModelContext,
1712
+ services: TDSServiceSchema[] | undefined,
1713
+ ): string | undefined {
1714
+ if (!services || services.length < 2) {
1715
+ return undefined;
1716
+ }
1717
+ if (!modelContext.entities.length || !modelContext.associations.length) {
1718
+ return undefined;
1719
+ }
1720
+ const serviceEntityMap = inferServiceEntityMapping(services, modelContext);
1721
+ if (serviceEntityMap.size === 0) {
1722
+ return undefined;
1723
+ }
1724
+ const joinLines: string[] = [
1725
+ '## Model-Aware Service JOIN Guide',
1726
+ 'Each service below is mapped to the data model entity whose properties best match its columns.',
1727
+ 'Use the entity relationships to determine correct JOIN keys and semantics.',
1728
+ '',
1729
+ ];
1730
+ for (const [svcTitle, entityPath] of serviceEntityMap) {
1731
+ joinLines.push(
1732
+ `- **${svcTitle}** → entity ${extractElementNameFromPath(entityPath)}`,
1733
+ );
1734
+ }
1735
+ const pairHints = buildServicePairHints(
1736
+ serviceEntityMap,
1737
+ modelContext,
1738
+ services,
1739
+ );
1740
+ if (pairHints.length > 0) {
1741
+ joinLines.push('', '### Inter-Service Relationships', ...pairHints);
1742
+ }
1743
+ return joinLines.join('\n');
1744
+ }
1745
+
1746
+ export function buildModelContextEnrichmentText(
1747
+ modelContext: LegendAIModelContext,
1748
+ services?: TDSServiceSchema[],
1749
+ ): string | undefined {
1750
+ const sections = [
1751
+ buildProductDescriptionSection(modelContext),
1752
+ buildEntitySection(modelContext),
1753
+ buildEnumerationSection(modelContext),
1754
+ buildAssociationSection(modelContext),
1755
+ buildExecutableSection(modelContext),
1756
+ buildColumnEnumMappingSection(modelContext, services),
1757
+ buildColumnDescriptionSection(modelContext, services),
1758
+ buildServiceJoinSection(modelContext, services),
1759
+ ].filter((s): s is string => s !== undefined);
1760
+
1761
+ if (sections.length === 0) {
1762
+ return undefined;
1763
+ }
1764
+
1765
+ return `# DATA MODEL CONTEXT
1766
+ 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.
1767
+
1768
+ ${sections.join('\n\n')}`;
1769
+ }