diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 9b59766..27be8f2 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -116,6 +116,20 @@ annotate Books with { } ``` +Fields annotated with `@UI.RecommendationState: 0` are excluded from predictions entirely. +A value of `1` (or omitting the annotation) means the field is eligible for recommendations. + +> **Note:** Since `@UI.RecommendationState` is a UI annotation, you must enable UI annotation loading +> in the Java runtime for it to take effect. By default, the CAP Java runtime strips `@UI.*` +> annotations from the in-memory model to reduce memory consumption (they are typically only +> needed for OData metadata generation, not for runtime logic). +> +> ```yaml +> cds: +> model: +> include-ui-annotations: true +> ``` + ## Configuration ```yaml diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index d0c105c..6843533 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -32,6 +32,7 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; private static final String ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist"; + private static final String RECOMMENDATION_STATE_ANNOTATION = "@UI.RecommendationState"; private static final String COMPUTED_ANNOTATION = "@Core.Computed"; private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = @@ -140,6 +141,10 @@ private List computePredictionElements() { .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) .filter(e -> !e.getType().isAssociation()) .filter(e -> !Boolean.FALSE.equals(e.getAnnotationValue(ODATA_VALUE_LIST_ANNOTATION, null))) + .filter( + e -> + !isRecommendationDisabled( + e.getAnnotationValue(RECOMMENDATION_STATE_ANNOTATION, null))) .map(CdsElement::getName) .toList(); } @@ -157,4 +162,8 @@ private List computeContextColumns() { .map(CdsElement::getName) .toList(); } + + private static boolean isRecommendationDisabled(Object annotationValue) { + return annotationValue instanceof Number n && n.intValue() == 0; + } } diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index b25faa9..d3f46e9 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -17,6 +17,8 @@ import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.Service; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.impl.utils.CdsServiceUtils; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.request.RequestContext; @@ -45,8 +47,10 @@ class FioriRecommendationHandlerTest { static void bootRuntime() { db = mock(PersistenceService.class); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + CdsProperties properties = new CdsProperties(); + properties.getModel().setIncludeUiAnnotations(true); runtime = - CdsRuntimeConfigurer.create() + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)) .cdsModel("model/csn.json") .serviceConfigurations() .service(db) @@ -243,6 +247,109 @@ void cdsoDataValueListFalse_fieldIsExcludedFromPredictions() { }); } + @Test + @SuppressWarnings("unchecked") + void recommendationStateZero_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has no @UI.RecommendationState → predicted + // disabled_ID has @UI.RecommendationState: 0 → excluded + // enabled_ID has @UI.RecommendationState: 1 → predicted + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("disabled_ID"); + assertThat(recs).containsKey("enabled_ID"); + }); + } + + @Test + void recommendationStateZero_fieldIsExcludedFromContextQuery() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // The context query WHERE clause should NOT require disabled_ID to be non-null + // (it is still a valid context *column* for SELECT, but not a prediction target) + CqnSelect contextQuery = selectCaptor.getAllValues().get(0); + String whereSql = contextQuery.where().map(Object::toString).orElse(""); + assertThat(whereSql).contains("genre_ID"); + assertThat(whereSql).contains("enabled_ID"); + assertThat(whereSql).doesNotContain("disabled_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index 5c30916..3e5f45d 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -58,6 +58,19 @@ entity BooksWithDisabledValueList { suppressed_ID : Integer; } +@odata.draft.enabled +entity BooksWithRecommendationState { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 0 + disabled_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 1 + enabled_ID : Integer; +} + service TestService { entity Books as projection on test.Books; entity Genres as projection on test.Genres; @@ -66,4 +79,5 @@ service TestService { entity IsbnBooks as projection on test.IsbnBooks; entity PlainEntity as projection on test.PlainEntity; entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; + entity BooksWithRecommendationState as projection on test.BooksWithRecommendationState; } diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index 73e07c4..b256334 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,6 +7,8 @@ spring: mode: always cds: + model: + include-ui-annotations: true ai: core: resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default}