From e7e925feb9fe56657626496bb2b7636ebd54a065 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 15 Jun 2026 16:51:42 +0200 Subject: [PATCH 01/33] Separate the predictionRow from the contextRows and add a constant for [PREDICT] --- .../FioriRecommendationHandler.java | 8 ++- .../MockRecommendationClient.java | 46 ++++++------- .../RecommendationContextBuilder.java | 30 +++++---- .../api/RecommendationClient.java | 12 +++- .../api/RptInferenceClient.java | 20 ++++-- .../FioriRecommendationHandlerTest.java | 64 ++++++------------- 6 files changed, 89 insertions(+), 91 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 36e9634..56d6ed4 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -121,11 +121,15 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List allRows = builder.assembleRows(contextRows, predictRow, row); + // RPT-1 requires a single string index column; entities with composite or non-ID keys get a + // synthetic key injected before the rows are sent to the model. + builder.addSyntheticKey(contextRows); + builder.addSyntheticKey(predictRow); RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = - client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); + client.predict( + predictRow, contextRows, builder.predictionElementNames(), builder.indexColumn()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 27498bb..42d0b19 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -5,7 +5,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import java.util.ArrayList; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,33 +17,25 @@ class MockRecommendationClient implements RecommendationClient { @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List availableValues = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object contextValue = - availableValues.isEmpty() - ? null - : availableValues.get(random.nextInt(availableValues.size())); - Map predictionEntry = new HashMap<>(); - predictionEntry.put("prediction", contextValue); - prediction.put(col, List.of(predictionEntry)); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn) { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (RptInferenceClient.PREDICT.equals(predictionRow.get(col))) { + List availableValues = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get(random.nextInt(availableValues.size())); + Map predictionEntry = new HashMap<>(); + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); } } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); } } 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 a158123..c15d24c 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 @@ -6,6 +6,7 @@ import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; import com.sap.cds.ql.cqn.CqnSelect; @@ -128,7 +129,7 @@ CdsData buildPredictRow(CdsData row) { Map predictRow = new HashMap<>(row); Drafts.ELEMENTS.forEach(predictRow::remove); for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); + predictRow.putIfAbsent(col, RptInferenceClient.PREDICT); } return CdsData.create(predictRow); } @@ -149,19 +150,24 @@ private String computeSyntheticKey(Map row) { return sb.toString(); } - List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { - List allRows = new ArrayList<>(); + /** + * Injects a synthetic index column into the given row if the entity has a composite or non-ID + * key. The synthetic key is a concatenation of all key fields, used as the RPT-1 index column. + * No-op when a plain {@code ID} key is sufficient. + */ + void addSyntheticKey(CdsData row) { if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(currentRow)); - } else { - allRows.addAll(contextRows); + row.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row)); + } + } + + /** + * Convenience overload that applies {@link #addSyntheticKey(CdsData)} to each row in the list. + */ + void addSyntheticKey(List rows) { + if (syntheticKeyNeeded) { + rows.forEach(this::addSyntheticKey); } - allRows.add(predictRow); - return allRows; } private List computePredictionElements() { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index 8694745..c2c036c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -8,5 +8,15 @@ public interface RecommendationClient { - List predict(List rows, List predictionColumns, String indexColumn); + // Currently limited to a single prediction row. Multiple prediction rows may be supported in the + // future via a separate overload, but are ruled out at two points for now: + // (1) FioriRecommendationHandler bails out when the read returns more than one entity, + // so predictions only fire on single-entity reads. + // (2) FioriRecommendationHandler also rejects responses with more than one prediction back from + // the model, treating it as an unexpected state. + List predict( + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index c6e4c3a..5de4d76 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -44,6 +44,9 @@ public class RptInferenceClient implements RecommendationClient { private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); + // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request + public static final String PREDICT = "[PREDICT]"; + private static final Set MANAGED_FIELDS = Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); @@ -58,11 +61,16 @@ public RptInferenceClient(ApiClient apiClient) { @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - PredictRequestPayload request = buildRequest(rows, predictionColumns, indexColumn); + CdsData predictionRow, + List contextRows, + List predictionColumns, + String indexColumn) { + List allRows = new java.util.ArrayList<>(contextRows); + allRows.add(predictionRow); + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn); logger.debug( - "Sending prediction request for {} rows, {} target columns", - rows.size(), + "Sending prediction request for one row with {} context rows, {} target columns", + contextRows.size(), predictionColumns.size()); return Retry.decorateSupplier( INFERENCE_RETRY, @@ -85,7 +93,7 @@ private static PredictRequestPayload buildRequest( col -> TargetColumnConfig.create() .name(col) - .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .predictionPlaceholder(PredictionPlaceholder.create(PREDICT)) .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); @@ -104,7 +112,7 @@ private static PredictRequestPayload buildRequest( }); for (String target : predictionColumns) { if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create("[PREDICT]")); + sdkRow.put(target, RowsInnerValue.create(PREDICT)); } } return sdkRow; 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 c7f1a22..fe3ac39 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 @@ -152,7 +152,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (rows, cols, idx) -> List.of(); + predictionClient = (predictionRow, contextRows, cols, idx) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -166,7 +166,7 @@ void multiplePredictions_returnsEarlyWithoutRecommendations() { CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); predictionClient = - (rows, cols, idx) -> + (predictionRow, contextRows, cols, idx) -> List.of( CdsData.create(Map.of("ID", "id-1")), CdsData.create(Map.of("ID", "id-2"))); cut.afterRead(ctx, dataList(row)); @@ -393,55 +393,33 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - if (predictionColumns.stream().noneMatch(col -> "[PREDICT]".equals(row.get(col)))) { - continue; - } - Map prediction = new HashMap<>(); - for (String col : predictionColumns) { - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = - available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if ("[PREDICT]".equals(predictionRow.get(col))) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } } - return predictions; + prediction.put(indexColumn, predictionRow.get(indexColumn)); + return List.of(CdsData.create(prediction)); }; } } From c2b35eda0e48739d6960b22d1ffcdeef1ba00e34 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Mon, 15 Jun 2026 17:19:09 +0200 Subject: [PATCH 02/33] Add comment on why we the @FunctionalInterface annotation is useful --- .../recommendation/api/RecommendationClientResolver.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index ecc6837..2f83755 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -5,6 +5,8 @@ import com.sap.cds.feature.aicore.api.AICoreService; +// The annotation @FunctionalInterface ensures this interface has only one method, such that +// callers can supply a custom client by providing this one method e.g. via a lambda. @FunctionalInterface public interface RecommendationClientResolver { From 5437c97326b7b1202e95b45e1f0c10d899f1fc4a Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 08:18:01 +0200 Subject: [PATCH 03/33] Add comment to FioriRecommendationHandler --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 56d6ed4..8d07153 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -81,6 +81,8 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + // rowType reflects the projected shape (columns actually selected); target is the full entity. + // Fall back to target when rowType is absent, e.g. when the result carries no type metadata. CdsStructuredType rowType = context.getResult().rowType(); if (rowType == null) { rowType = target; From 3bd12c7121762a8cc7aec0b3a154e2df26e9569a Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 11:14:44 +0200 Subject: [PATCH 04/33] Moved everthing RPT-1 specific into RptInferenceClient --- .../FioriRecommendationHandler.java | 7 +-- .../MockRecommendationClient.java | 8 +-- .../RecommendationContextBuilder.java | 58 ++----------------- .../api/RecommendationClient.java | 8 ++- .../api/RptInferenceClient.java | 47 ++++++++++++++- .../FioriRecommendationHandlerTest.java | 12 ++-- 6 files changed, 67 insertions(+), 73 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 8d07153..8cb17ef 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -123,15 +123,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - // RPT-1 requires a single string index column; entities with composite or non-ID keys get a - // synthetic key injected before the rows are sent to the model. - builder.addSyntheticKey(contextRows); - builder.addSyntheticKey(predictRow); - RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict( - predictRow, contextRows, builder.predictionElementNames(), builder.indexColumn()); + predictRow, contextRows, builder.predictionElementNames(), builder.keyNames()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 42d0b19..c4a4540 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -5,7 +5,6 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import com.sap.cds.feature.recommendation.api.RptInferenceClient; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -20,10 +19,11 @@ public List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn) { + List keyNames) { + String indexColumn = keyNames.size() == 1 ? keyNames.get(0) : "SAP_RECOMMENDATIONS_ID"; Map prediction = new HashMap<>(); for (String col : predictionColumns) { - if (RptInferenceClient.PREDICT.equals(predictionRow.get(col))) { + if (predictionRow.get(col) == null) { List availableValues = contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); Object contextValue = @@ -35,7 +35,7 @@ public List predict( prediction.put(col, List.of(predictionEntry)); } } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); } } 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 c15d24c..8db5d24 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 @@ -6,7 +6,6 @@ import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; import com.sap.cds.CdsData; -import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; import com.sap.cds.ql.cqn.CqnSelect; @@ -24,14 +23,14 @@ /** * Builds the context data needed for prediction: determines which elements to predict, which - * columns provide context, builds the context query, and prepares rows for the AI model. + * columns provide context and builds the context query. This class is cds-model aware, but does not + * know about which client will be used for the predictions. */ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; - private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; private static final Set SUPPORTED_CONTEXT_TYPES = EnumSet.of( CdsBaseType.STRING, @@ -65,8 +64,6 @@ class RecommendationContextBuilder { private final List predictionElementNames; private final List contextColumns; private final List keyNames; - private final boolean syntheticKeyNeeded; - private final String indexColumn; RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) { this.target = target; @@ -75,10 +72,6 @@ class RecommendationContextBuilder { this.predictionElementNames = computePredictionElements(); this.contextColumns = computeContextColumns(); this.keyNames = target.keyElements().map(CdsElement::getName).toList(); - this.syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - this.indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); } List predictionElementNames() { @@ -89,12 +82,8 @@ List contextColumns() { return contextColumns; } - String indexColumn() { - return indexColumn; - } - - boolean syntheticKeyNeeded() { - return syntheticKeyNeeded; + List keyNames() { + return keyNames; } CqnSelect buildContextQuery() { @@ -128,48 +117,9 @@ CdsData buildPredictRow(CdsData row) { } Map predictRow = new HashMap<>(row); Drafts.ELEMENTS.forEach(predictRow::remove); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, RptInferenceClient.PREDICT); - } return CdsData.create(predictRow); } - private String computeSyntheticKey(Map row) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < keyNames.size(); i++) { - if (i > 0) { - sb.append('\0'); - } - sb.append(keyNames.get(i)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - /** - * Injects a synthetic index column into the given row if the entity has a composite or non-ID - * key. The synthetic key is a concatenation of all key fields, used as the RPT-1 index column. - * No-op when a plain {@code ID} key is sufficient. - */ - void addSyntheticKey(CdsData row) { - if (syntheticKeyNeeded) { - row.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row)); - } - } - - /** - * Convenience overload that applies {@link #addSyntheticKey(CdsData)} to each row in the list. - */ - void addSyntheticKey(List rows) { - if (syntheticKeyNeeded) { - rows.forEach(this::addSyntheticKey); - } - } - private List computePredictionElements() { return rowType .elements() diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index c2c036c..1170780 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -14,9 +14,15 @@ public interface RecommendationClient { // so predictions only fire on single-entity reads. // (2) FioriRecommendationHandler also rejects responses with more than one prediction back from // the model, treating it as an unexpected state. + // + // @param predictionRow the single entity row to predict values for; prediction columns contain + // null for missing values that the model should fill + // @param contextRows historical rows from the same entity used as training context + // @param predictionColumns names of the columns the model should predict + // @param keyNames names of the entity's key columns, used to identify rows in the response List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn); + List keyNames); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 5de4d76..9639d8b 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -64,9 +64,13 @@ public List predict( CdsData predictionRow, List contextRows, List predictionColumns, - String indexColumn) { + List keyNames) { + String indexColumn = resolveIndexColumn(keyNames); + CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new java.util.ArrayList<>(contextRows); - allRows.add(predictionRow); + allRows.add(preparedPredictRow); + addSyntheticKeyIfNeeded(allRows, keyNames, indexColumn); + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn); logger.debug( "Sending prediction request for one row with {} context rows, {} target columns", @@ -85,6 +89,45 @@ public List predict( .get(); } + // RPT-1 specific: when the entity has a composite or non-ID key, a synthetic string index column + // is computed by concatenating all key fields and injected into each row before sending. + private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; + + // If there is one key, use it directly and don't compute a synthetic key + private static String resolveIndexColumn(List keyNames) { + return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; + } + + // Compute the synthetic key as a concatenated string from the keys listed in keyNames + private static String computeSyntheticKey(Map row, List keyNames) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) sb.append('\0'); + sb.append(keyNames.get(i)).append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) sb.append(value); + } + return sb.toString(); + } + + private static void addSyntheticKeyIfNeeded( + List rows, List keyNames, String indexColumn) { + if (SYNTHETIC_INDEX_COLUMN.equals(indexColumn)) { + rows.forEach(r -> r.put(SYNTHETIC_INDEX_COLUMN, computeSyntheticKey(r, keyNames))); + } + } + + // Returns a copy of the predictRow without the fields in Drafts.ELEMENTS and with a + // prediction placeholder for empty values in the predictinonColumns + private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { + Map preparedPredictRowMap = new HashMap<>(predictRow); + // Drafts.ELEMENTS.forEach(preparedPredictRowMap::remove); + for (String col : predictionColumns) { + preparedPredictRowMap.putIfAbsent(col, PREDICT); + } + return CdsData.create(preparedPredictRowMap); + } + private static PredictRequestPayload buildRequest( List rows, List predictionColumns, String indexColumn) { var targetColumns = 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 fe3ac39..efe4700 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 @@ -152,7 +152,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (predictionRow, contextRows, cols, idx) -> List.of(); + predictionClient = (predictionRow, contextRows, cols, keyNames) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -393,7 +393,7 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + return (predictionRow, contextRows, predictionColumns, keyNames) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { List available = @@ -401,24 +401,24 @@ private static RecommendationClient rptStyleClient() { Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); prediction.put(col, List.of(Map.of("prediction", val))); } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, indexColumn) -> { + return (predictionRow, contextRows, predictionColumns, keyNames) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { - if ("[PREDICT]".equals(predictionRow.get(col))) { + if (predictionRow.get(col) == null) { List available = contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); prediction.put(col, List.of(Map.of("prediction", val))); } } - prediction.put(indexColumn, predictionRow.get(indexColumn)); + prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); return List.of(CdsData.create(prediction)); }; } From 28046c7a884f749805632094992e3189d6c8dfdd Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 13:37:03 +0200 Subject: [PATCH 05/33] Rename RptInferenceClient.api -> RptInferenceClient.rpt and extract code that creates sdkRow to separate method --- .../api/RptInferenceClient.java | 44 +++++++++---------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 9639d8b..1e21d7d 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -52,10 +52,10 @@ public class RptInferenceClient implements RecommendationClient { private static final Retry INFERENCE_RETRY = buildInferenceRetry(); - private final DefaultApi api; + private final DefaultApi rpt; public RptInferenceClient(ApiClient apiClient) { - this.api = + this.rpt = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); } @@ -79,7 +79,7 @@ public List predict( return Retry.decorateSupplier( INFERENCE_RETRY, () -> { - var response = api.predict(request); + var response = rpt.predict(request); logger.debug("Prediction response id: {}", response.getId()); List> raw = JacksonConfiguration.getDefaultObjectMapper() @@ -140,27 +140,7 @@ private static PredictRequestPayload buildRequest( .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); - var sdkRows = - rows.stream() - .map( - row -> { - Map sdkRow = new HashMap<>(); - row.forEach( - (k, v) -> { - if (v != null - && !Drafts.ELEMENTS.contains(k) - && !MANAGED_FIELDS.contains(k)) { - sdkRow.put(k, RowsInnerValue.create(v.toString())); - } - }); - for (String target : predictionColumns) { - if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create(PREDICT)); - } - } - return sdkRow; - }) - .toList(); + var sdkRows = rows.stream().map(row -> toSdkRow(row, predictionColumns)).toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) @@ -168,6 +148,22 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } + private static Map toSdkRow(CdsData row, List predictionColumns) { + Map sdkRow = new HashMap<>(); + row.forEach( + (k, v) -> { + if (v != null && !Drafts.ELEMENTS.contains(k) && !MANAGED_FIELDS.contains(k)) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + for (String target : predictionColumns) { + if (!row.containsKey(target) || row.get(target) == null) { + sdkRow.put(target, RowsInnerValue.create(PREDICT)); + } + } + return sdkRow; + } + private static Retry buildInferenceRetry() { RetryConfig config = RetryConfig.custom() From 9c1abbecab5d8176b885b63a882f608962d60dbf Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 13:44:11 +0200 Subject: [PATCH 06/33] Moved everthing RPT-1 specific into RptInferenceClient --- .../cds/feature/recommendation/api/RptInferenceClient.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 1e21d7d..206fb16 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -117,11 +117,10 @@ private static void addSyntheticKeyIfNeeded( } } - // Returns a copy of the predictRow without the fields in Drafts.ELEMENTS and with a - // prediction placeholder for empty values in the predictinonColumns + // Returns a copy of the predictRow with a prediction placeholder replacing empty values + // in the predictionColumns - these will get filled by the predict method. private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { Map preparedPredictRowMap = new HashMap<>(predictRow); - // Drafts.ELEMENTS.forEach(preparedPredictRowMap::remove); for (String col : predictionColumns) { preparedPredictRowMap.putIfAbsent(col, PREDICT); } From 59d11eafdfb7ca0ad9cfd3d0c7b54dc4d27988e6 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:38:41 +0200 Subject: [PATCH 07/33] Replace MANAGED_FIELDS set with annotation-driven exclusion: @Core.Computed, @readonly and remove logic from 'toSdkRow' method that was already executed elsewhere --- .../RecommendationContextBuilder.java | 17 +++++++++++++++-- .../recommendation/api/RptInferenceClient.java | 17 ++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) 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 8db5d24..2fa1da5 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 @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -31,6 +32,8 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; + private static final String COMPUTED_ANNOTATION = "@Core.Computed"; + private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = EnumSet.of( CdsBaseType.STRING, @@ -111,12 +114,20 @@ CqnSelect buildContextQuery() { return select; } + // Builds the predict row from only the allowed columns (same set used in buildContextQuery), + // so draft, computed, and readonly fields are excluded by construction rather than explicit + // removal. CdsData buildPredictRow(CdsData row) { if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { return null; } - Map predictRow = new HashMap<>(row); - Drafts.ELEMENTS.forEach(predictRow::remove); + Set allowed = new HashSet<>(contextColumns); + allowed.addAll(keyNames); + Map predictRow = new HashMap<>(); + allowed.forEach( + col -> { + if (row.containsKey(col)) predictRow.put(col, row.get(col)); + }); return CdsData.create(predictRow); } @@ -138,6 +149,8 @@ private List computeContextColumns() { .filter( e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) + .filter(byAnnotation(READONLY_ANNOTATION).negate()) .map(CdsElement::getName) .toList(); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 206fb16..e71ee3a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -12,7 +12,6 @@ import com.sap.ai.sdk.foundationmodels.rpt.generated.model.RowsInnerValue; import com.sap.ai.sdk.foundationmodels.rpt.generated.model.TargetColumnConfig; import com.sap.cds.CdsData; -import com.sap.cds.services.draft.Drafts; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; @@ -21,7 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,9 +45,6 @@ public class RptInferenceClient implements RecommendationClient { // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request public static final String PREDICT = "[PREDICT]"; - private static final Set MANAGED_FIELDS = - Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); - private static final Retry INFERENCE_RETRY = buildInferenceRetry(); private final DefaultApi rpt; @@ -139,7 +134,7 @@ private static PredictRequestPayload buildRequest( .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); - var sdkRows = rows.stream().map(row -> toSdkRow(row, predictionColumns)).toList(); + var sdkRows = rows.stream().map(row -> toSdkRow(row)).toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) @@ -147,19 +142,15 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } - private static Map toSdkRow(CdsData row, List predictionColumns) { + // Converts a CdsData row to the RPT SDK row format, i.e., into Map + private static Map toSdkRow(CdsData row) { Map sdkRow = new HashMap<>(); row.forEach( (k, v) -> { - if (v != null && !Drafts.ELEMENTS.contains(k) && !MANAGED_FIELDS.contains(k)) { + if (v != null) { sdkRow.put(k, RowsInnerValue.create(v.toString())); } }); - for (String target : predictionColumns) { - if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create(PREDICT)); - } - } return sdkRow; } From e0f491ff97decba2728c4f34331253a23339332f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:44:46 +0200 Subject: [PATCH 08/33] Change level of log statement when no suitable context columns are found and recommendations are therefore skipped --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 8cb17ef..0fd05db 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -103,7 +103,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { } if (builder.contextColumns().isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); + logger.trace("No suitable context columns found, skipping predictions."); return; } From 7c2a8cb17201ea18e7785102b27772391eb14a7b Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:52:55 +0200 Subject: [PATCH 09/33] Move 'return early if predictRow == null' to earlier in afterRead of FioriRecommendationHandler --- .../recommendation/FioriRecommendationHandler.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 0fd05db..e6544eb 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -111,11 +111,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { context .getServiceCatalog() .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); - if (contextRows.size() < 2) { - logger.debug("Not enough context rows (minimum 2), skipping predictions."); - return; - } CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { @@ -123,6 +118,12 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = client.predict( From d6c8f00f86ee97f7d4492927b230f2b40b8c5ea8 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:54:36 +0200 Subject: [PATCH 10/33] Extract 'SAP_Recommendations' into a constant --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index e6544eb..1cf1b0e 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -29,6 +29,7 @@ class FioriRecommendationHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; + private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; @@ -143,6 +144,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { Map recommendations = resultParser.buildRecommendations( db, predictions.get(0), missingPredictionElementNames, context, rowType); - row.put("SAP_Recommendations", recommendations); + row.put(SAP_RECOMMENDATIONS, recommendations); } } From e39677fb7f025d0d785b9547baac6e3b9f9aa146 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:56:06 +0200 Subject: [PATCH 11/33] Get Persistence Service db via Dependency Injection --- .../recommendation/FioriRecommendationHandler.java | 10 +++++----- .../recommendation/RecommendationConfiguration.java | 7 ++++++- .../recommendation/FioriRecommendationHandlerTest.java | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 1cf1b0e..899430c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -33,6 +33,7 @@ class FioriRecommendationHandler implements EventHandler { private final AICoreService aiCoreService; private final RecommendationClientResolver clientResolver; + private final PersistenceService db; private final RecommendationResultParser resultParser = new RecommendationResultParser(); // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction // columns. Keys are ":" because if an entity needs a prediction can be @@ -41,9 +42,12 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, RecommendationClientResolver clientResolver) { + AICoreService aiCoreService, + RecommendationClientResolver clientResolver, + PersistenceService db) { this.aiCoreService = aiCoreService; this.clientResolver = clientResolver; + this.db = db; } void invalidateTenant(String tenantId) { @@ -108,10 +112,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - PersistenceService db = - context - .getServiceCatalog() - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index afde35c..9f66ead 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -9,6 +9,7 @@ import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -33,13 +34,17 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { return; } + PersistenceService db = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = hasBind ? RecommendationConfiguration::resolveRptClient : service -> new MockRecommendationClient(); - FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + FioriRecommendationHandler handler = + new FioriRecommendationHandler(aiCoreService, resolver, db); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } 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 efe4700..55f6787 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 @@ -67,7 +67,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); + cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient, db); } // ── tests ────────────────────────────────────────────────────────────────── From fc3926c31b10075c08ce4627aeb7434595aa8e8f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 14:59:12 +0200 Subject: [PATCH 12/33] Move missingPredictionElementNames to earlier in the afterRead of the FioriRecommendationHandler --- .../recommendation/FioriRecommendationHandler.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 899430c..2949703 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -112,7 +112,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { logger.debug("Current row already has values for all prediction columns, skipping."); @@ -125,10 +124,12 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); + RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = - client.predict( - predictRow, contextRows, builder.predictionElementNames(), builder.keyNames()); + client.predict(predictRow, contextRows, missingPredictionElementNames, builder.keyNames()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -139,8 +140,6 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List missingPredictionElementNames = - builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = resultParser.buildRecommendations( db, predictions.get(0), missingPredictionElementNames, context, rowType); From f790bbfcb642da4e4ce77e065256d8c066bc78d1 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:12:12 +0200 Subject: [PATCH 13/33] Add comment about conversion from List to List --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 2949703..36a76ee 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -118,6 +118,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + // Result.list() returns List; the ArrayList copy also converts it to List. List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); if (contextRows.size() < 2) { logger.debug("Not enough context rows (minimum 2), skipping predictions."); From 68bb6e1cdc33bdbbadca8ce1602479a63da14e85 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:15:13 +0200 Subject: [PATCH 14/33] Change check for active Entity: only return early if isActiveEntity is selected and false --- .../cds/feature/recommendation/FioriRecommendationHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 36a76ee..d89bfaf 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -82,7 +82,8 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - if (!Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { + if (row.containsKey(Drafts.IS_ACTIVE_ENTITY) + && !Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { return; } From b60c3c1961df9bb7f91c8abf9caf3d00cba9cf9b Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:22:06 +0200 Subject: [PATCH 15/33] Add comments to MockRecommendationClient --- .../feature/recommendation/MockRecommendationClient.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index c4a4540..2a54ed7 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -10,8 +10,13 @@ import java.util.Map; import java.util.Random; +// Mock implementation used when no AI Core binding is present. For each prediction column that +// is null in the predict row, it picks a random non-null value from the same column across the +// context rows and returns it as the prediction. Columns already filled are left unchanged. class MockRecommendationClient implements RecommendationClient { + // We use random here so you can see a difference in the UI. The actual value returned here is not + // relevant for tests. private final Random random = new Random(); @Override @@ -31,6 +36,8 @@ public List predict( ? null : availableValues.get(random.nextInt(availableValues.size())); Map predictionEntry = new HashMap<>(); + // Replace the empty entry in col with a randomly picked value of entries in the + // contextRows. predictionEntry.put("prediction", contextValue); prediction.put(col, List.of(predictionEntry)); } From bcf16def4070ce848c3b958bd79f09edb233bef2 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:35:44 +0200 Subject: [PATCH 16/33] Change type of slectcolumns to Set --- .../recommendation/RecommendationContextBuilder.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 2fa1da5..637af6b 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 @@ -14,7 +14,6 @@ import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; -import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -90,12 +89,9 @@ List keyNames() { } CqnSelect buildContextQuery() { - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } + Set selectColumns = new HashSet<>(contextColumns); + selectColumns.addAll(keyNames); + var select = Select.from(target.getQualifiedName()) .columns(selectColumns.toArray(String[]::new)) From 3d40bec00152876d27d5eda4283da3b987641045 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:43:00 +0200 Subject: [PATCH 17/33] Add test: row for which we want to do predictions is automatically excluded in the select query returned by buildContextQuery --- .../RecommendationContextBuilder.java | 2 ++ .../FioriRecommendationHandlerTest.java | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) 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 637af6b..308fcae 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 @@ -97,6 +97,8 @@ CqnSelect buildContextQuery() { .columns(selectColumns.toArray(String[]::new)) .where( predictionElementNames.stream() + // the row for which we want to do predictions is automatically + // excluded by this isNotNull check .map(col -> CQL.get(col).isNotNull()) .collect(CQL.withAnd())) .limit(contextRowLimit); 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 55f6787..2476e33 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 @@ -203,6 +203,23 @@ void draftRow_withGenreAndCurrency_addsSapRecommendations() { }); } + @Test + void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + // The WHERE clause requires all prediction columns to be non-null, so the current row + // (which has genre_ID = null) is automatically excluded from the context. + String selectSql = selectCaptor.getAllValues().get(0).toString(); + assertThat(selectSql).contains("\"is not\",\"null\""); + assertThat(selectSql).contains("genre_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( From 5339f522b475de44fcd455c1fb73e6f6bb2fc455 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:44:55 +0200 Subject: [PATCH 18/33] Add comment about ordering in the select query returned by buildContextQuery --- .../feature/recommendation/RecommendationContextBuilder.java | 2 ++ 1 file changed, 2 insertions(+) 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 308fcae..ba5597a 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 @@ -103,6 +103,8 @@ CqnSelect buildContextQuery() { .collect(CQL.withAnd())) .limit(contextRowLimit); target + // ensure there is some stable ordering of the contextRows, if possible order by + // "most recently changed" so the model gets the most up-to-date data .concreteNonAssociationElements() .filter(byAnnotation("cds.on.update")) .map(CdsElement::getName) From aad972db8acd2c64a7df128c3a975be46a8bfdc5 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 15:53:32 +0200 Subject: [PATCH 19/33] Add comment computeSyntheticKey method and add test --- .../api/RptInferenceClient.java | 5 +- .../api/RptInferenceClientTest.java | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index e71ee3a..b376c45 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -93,8 +93,9 @@ private static String resolveIndexColumn(List keyNames) { return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; } - // Compute the synthetic key as a concatenated string from the keys listed in keyNames - private static String computeSyntheticKey(Map row, List keyNames) { + // '\0' is used as separator because it cannot appear in database string values + // (VARCHAR/NVARCHAR), so concatenation of any composite key values is guaranteed collision-free. + static String computeSyntheticKey(Map row, List keyNames) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < keyNames.size(); i++) { if (i > 0) sb.append('\0'); diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java new file mode 100644 index 0000000..8abce82 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,48 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RptInferenceClientTest { + + @Test + void computeSyntheticKey_singleKey() { + String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); + assertThat(key).isEqualTo("ID" + '\0' + "abc"); + } + + @Test + void computeSyntheticKey_compositeKey() { + String key = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", 1, "item_no", 10), List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0' + "10"); + } + + @Test + void computeSyntheticKey_noCollision_betweenDifferentCompositions() { + // "1" + "0" must not produce the same key as "10" + "" + String key1 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "1", "item_no", "0"), List.of("order_ID", "item_no")); + String key2 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "10", "item_no", ""), List.of("order_ID", "item_no")); + assertThat(key1).isNotEqualTo(key2); + } + + @Test + void computeSyntheticKey_nullValue_doesNotCrash() { + Map row = new java.util.HashMap<>(); + row.put("order_ID", 1); + row.put("item_no", null); + String key = RptInferenceClient.computeSyntheticKey(row, List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0'); + } +} From 183348590decd84b1347b6a96df65047740f066f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:26:02 +0200 Subject: [PATCH 20/33] Make sure we dont react on @cds.odata.valuelist : false and add a test for that --- .../RecommendationContextBuilder.java | 2 ++ .../FioriRecommendationHandlerTest.java | 32 +++++++++++++++++++ .../resources/model/recommendations-test.cds | 11 +++++++ 3 files changed, 45 insertions(+) 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 ba5597a..c3b9368 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 @@ -31,6 +31,7 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; 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 COMPUTED_ANNOTATION = "@Core.Computed"; private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = @@ -138,6 +139,7 @@ private List computePredictionElements() { byAnnotation(VALUE_LIST_ANNOTATION) .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))) .map(CdsElement::getName) .toList(); } 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 2476e33..48a8c25 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 @@ -220,6 +220,38 @@ void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { }); } + @Test + void cdsoDataValueListFalse_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("suppressed_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithDisabledValueList", 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, "suppressed_ID", 10)), + new HashMap<>( + Map.of("ID", "x2", "genre_ID", 2, "suppressed_ID", 20))))) + .result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has @Common.ValueListWithFixedValues → predicted + // suppressed_ID has @cds.odata.valuelist: false → excluded + assertThat(row).containsKey("SAP_Recommendations"); + @SuppressWarnings("unchecked") + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("suppressed_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 bfcd611..66cbe9a 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -46,6 +46,16 @@ entity PlainEntity { title : String; } +@odata.draft.enabled +entity BooksWithDisabledValueList { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @cds.odata.valuelist: false + suppressed_ID : Integer; +} + service TestService { entity Books as projection on test.Books; entity Genres as projection on test.Genres; @@ -53,4 +63,5 @@ service TestService { entity OrderItems as projection on test.OrderItems; entity IsbnBooks as projection on test.IsbnBooks; entity PlainEntity as projection on test.PlainEntity; + entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; } From ec4deea0a06ec0362aacb1c1ff6a922632e0146f Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:32:06 +0200 Subject: [PATCH 21/33] Simplify filters in computeContextColumn --- .../feature/recommendation/RecommendationContextBuilder.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 c3b9368..4c335e2 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 @@ -147,9 +147,10 @@ private List computePredictionElements() { private List computeContextColumns() { return rowType .concreteNonAssociationElements() - .filter(e -> e.getType().isSimple()) .filter( - e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + e -> + e.getType() instanceof CdsSimpleType st + && SUPPORTED_CONTEXT_TYPES.contains(st.getType())) .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) .filter(byAnnotation(READONLY_ANNOTATION).negate()) From fa7e5104e811f7805cd5398a6f871d06cad83dac Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:38:38 +0200 Subject: [PATCH 22/33] Use any single key as RPT-1 index column, not just fields called ID and add test --- .../api/RptInferenceClient.java | 5 ++-- .../api/RptInferenceClientTest.java | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index b376c45..5269daa 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -88,9 +88,10 @@ public List predict( // is computed by concatenating all key fields and injected into each row before sending. private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; - // If there is one key, use it directly and don't compute a synthetic key + // If there is one key, use it directly; for composite keys a synthetic string column is needed + // since RPT-1 requires a single string index column. private static String resolveIndexColumn(List keyNames) { - return (keyNames.size() == 1 && "ID".equals(keyNames.get(0))) ? "ID" : SYNTHETIC_INDEX_COLUMN; + return keyNames.size() == 1 ? keyNames.get(0) : SYNTHETIC_INDEX_COLUMN; } // '\0' is used as separator because it cannot appear in database string values diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index 8abce82..e57d231 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,12 +5,35 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; class RptInferenceClientTest { + private static String resolveIndexColumn(List keyNames) throws Exception { + Method m = RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class); + m.setAccessible(true); + return (String) m.invoke(null, keyNames); + } + + @Test + void resolveIndexColumn_singleKey_usesItDirectly() throws Exception { + assertThat(resolveIndexColumn(List.of("isbn"))).isEqualTo("isbn"); + } + + @Test + void resolveIndexColumn_singleKeyNamedId_usesItDirectly() throws Exception { + assertThat(resolveIndexColumn(List.of("ID"))).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_compositeKey_usesSyntheticColumn() throws Exception { + assertThat(resolveIndexColumn(List.of("order_ID", "item_no"))) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + @Test void computeSyntheticKey_singleKey() { String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); From 1e51867c5001dd95800a97acd82142bb545dc7da Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:41:21 +0200 Subject: [PATCH 23/33] Adjust sample to changes in cds-feature-recomendations --- .../handlers/AICoreShowcaseHandler.java | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index ebaa19f..1cff6be 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -112,45 +112,39 @@ public void onCreateConfiguration(EventContext context) { public void onPredictCategory(EventContext context) { List> products = (List>) context.get("products"); - List rows = new ArrayList<>(); - rows.add( - CdsData.create( - Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); - - for (Map product : products) { - Map row = new HashMap<>(product); - row.put("category", "[PREDICT]"); - rows.add(CdsData.create(row)); - } + List contextRows = + List.of( + CdsData.create( + Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing")), + CdsData.create( + Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books")), + CdsData.create( + Map.of( + "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); AICoreService service = getAICoreService(); String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - List predictions = client.predict(rows, List.of("category"), "ID"); + RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); List> results = new ArrayList<>(); - for (CdsData prediction : predictions) { - String id = (String) prediction.get("ID"); - Object categoryObj = prediction.get("category"); - String category = - categoryObj instanceof List list && !list.isEmpty() - ? extractPrediction(list) - : String.valueOf(categoryObj); - results.add(Map.of("ID", id, "category", category)); + for (Map product : products) { + CdsData predictionRow = CdsData.create(new HashMap<>(product)); + List predictions = + client.predict(predictionRow, contextRows, List.of("category"), List.of("ID")); + for (CdsData prediction : predictions) { + String id = (String) prediction.get("ID"); + Object categoryObj = prediction.get("category"); + String category = + categoryObj instanceof List list && !list.isEmpty() + ? extractPrediction(list) + : String.valueOf(categoryObj); + results.add(Map.of("ID", id, "category", category)); + } } context.put("result", results); From 793d144d8b3c8ff453bf49edef6b38547c99d3ba Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:50:00 +0200 Subject: [PATCH 24/33] Add comment about books entity in test model --- .../src/test/resources/model/recommendations-test.cds | 2 ++ 1 file changed, 2 insertions(+) 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 66cbe9a..5c30916 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -1,5 +1,7 @@ namespace test; +// genre_ID and currency_code are declared as plain scalars here because this is a test model. +// In a real CDS model these would be generated foreign key columns from annotated associations. @odata.draft.enabled entity Books { key ID : UUID; From 23e644ca7ac1348ef6e432b109d5151594e0fc00 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 16:59:31 +0200 Subject: [PATCH 25/33] Update javadoc --- .../sap/cds/feature/recommendation/api/RptInferenceClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 5269daa..8e11a60 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -35,7 +35,7 @@ * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - * List predictions = client.predict(rows, List.of("targetColumn"), "ID"); + * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"), List.of("ID")); * } */ public class RptInferenceClient implements RecommendationClient { From 45f45de8f4c4c0b431e98f6ceb09b6c83bb514f0 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:00:06 +0200 Subject: [PATCH 26/33] Do not register FioriRecommendationHandler if no PersistenceService is found --- .../feature/recommendation/RecommendationConfiguration.java | 6 ++++++ .../recommendation/RecommendationConfigurationTest.java | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 9f66ead..e31a497 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -37,6 +37,12 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { PersistenceService db = serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + if (db == null) { + logger.info( + "No PersistenceService found, skipping Fiori recommendation handler registration."); + return; + } + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = hasBind diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index 69b5062..8b3ebce 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -8,6 +8,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.stream.Stream; @@ -24,6 +25,7 @@ class RecommendationConfigurationTest { @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; @Mock private AICoreService aiCoreService; + @Mock private PersistenceService persistenceService; @Test void aiCoreServiceFound_registersHandler() { @@ -33,6 +35,8 @@ void aiCoreServiceFound_registersHandler() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); new RecommendationConfiguration().eventHandlers(configurer); From fa4966eeb8cb084cfc0ced5c7ee3a82ccfc6997c Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:12:51 +0200 Subject: [PATCH 27/33] In RptInferenceClient:resolveIndexColumn: fall back to synthetic index column for non-string single keys --- .../api/RptInferenceClient.java | 15 ++++++---- .../api/RptInferenceClientTest.java | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 8e11a60..6bb189f 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -60,7 +60,7 @@ public List predict( List contextRows, List predictionColumns, List keyNames) { - String indexColumn = resolveIndexColumn(keyNames); + String indexColumn = resolveIndexColumn(keyNames, predictionRow); CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new java.util.ArrayList<>(contextRows); allRows.add(preparedPredictRow); @@ -88,10 +88,15 @@ public List predict( // is computed by concatenating all key fields and injected into each row before sending. private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; - // If there is one key, use it directly; for composite keys a synthetic string column is needed - // since RPT-1 requires a single string index column. - private static String resolveIndexColumn(List keyNames) { - return keyNames.size() == 1 ? keyNames.get(0) : SYNTHETIC_INDEX_COLUMN; + // If there is one string-typed key, use it directly; for composite keys or non-string keys a + // synthetic string column is needed since RPT-1 requires a single string index column. + // Non-string single keys fall back to synthetic rather than just converting to a string. + // RPT-1 may reject a column declared as the index if its values are not strings. + private static String resolveIndexColumn(List keyNames, CdsData sampleRow) { + if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { + return keyNames.get(0); + } + return SYNTHETIC_INDEX_COLUMN; } // '\0' is used as separator because it cannot appear in database string values diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index e57d231..5784e21 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.sap.cds.CdsData; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -12,25 +13,36 @@ class RptInferenceClientTest { - private static String resolveIndexColumn(List keyNames) throws Exception { - Method m = RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class); + private static String resolveIndexColumn(List keyNames, CdsData sampleRow) + throws Exception { + Method m = + RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class, CdsData.class); m.setAccessible(true); - return (String) m.invoke(null, keyNames); + return (String) m.invoke(null, keyNames, sampleRow); } @Test - void resolveIndexColumn_singleKey_usesItDirectly() throws Exception { - assertThat(resolveIndexColumn(List.of("isbn"))).isEqualTo("isbn"); + void resolveIndexColumn_singleStringKey_usesItDirectly() throws Exception { + CdsData row = CdsData.create(Map.of("isbn", "978-3-16")); + assertThat(resolveIndexColumn(List.of("isbn"), row)).isEqualTo("isbn"); } @Test - void resolveIndexColumn_singleKeyNamedId_usesItDirectly() throws Exception { - assertThat(resolveIndexColumn(List.of("ID"))).isEqualTo("ID"); + void resolveIndexColumn_singleUuidKey_usesItDirectly() throws Exception { + CdsData row = CdsData.create(Map.of("ID", "a009c640-434a-4542-ac68-51b400c880ec")); + assertThat(resolveIndexColumn(List.of("ID"), row)).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_singleIntegerKey_usesSyntheticColumn() throws Exception { + CdsData row = CdsData.create(Map.of("order_ID", 42)); + assertThat(resolveIndexColumn(List.of("order_ID"), row)).isEqualTo("SAP_RECOMMENDATIONS_ID"); } @Test void resolveIndexColumn_compositeKey_usesSyntheticColumn() throws Exception { - assertThat(resolveIndexColumn(List.of("order_ID", "item_no"))) + CdsData row = CdsData.create(Map.of("order_ID", 1, "item_no", 10)); + assertThat(resolveIndexColumn(List.of("order_ID", "item_no"), row)) .isEqualTo("SAP_RECOMMENDATIONS_ID"); } From 1cfc6cdea49d6df233cda2994c9c669cec0ee0cb Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 16 Jun 2026 17:16:05 +0200 Subject: [PATCH 28/33] Add checks for the presence of keyNames --- .../feature/recommendation/FioriRecommendationHandler.java | 5 +++++ .../cds/feature/recommendation/MockRecommendationClient.java | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index d89bfaf..7a539ff 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -108,6 +108,11 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } + if (builder.keyNames().isEmpty()) { + logger.debug("Entity has no key elements, skipping predictions."); + return; + } + if (builder.contextColumns().isEmpty()) { logger.trace("No suitable context columns found, skipping predictions."); return; diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 2a54ed7..cb2b64c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -42,7 +42,9 @@ public List predict( prediction.put(col, List.of(predictionEntry)); } } - prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + if (!keyNames.isEmpty()) { + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + } return List.of(CdsData.create(prediction)); } } From 4b9599ed3dd54cfceb4e15965489fccf1f75a106 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 12:40:30 +0200 Subject: [PATCH 29/33] Minor changes --- .../RecommendationContextBuilder.java | 11 +++---- .../api/RecommendationClient.java | 32 ++++++++++++------- .../api/RptInferenceClient.java | 3 +- 3 files changed, 27 insertions(+), 19 deletions(-) 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 4c335e2..049d6e5 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 @@ -31,7 +31,7 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; 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 ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist"; private static final String COMPUTED_ANNOTATION = "@Core.Computed"; private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = @@ -124,11 +124,10 @@ CdsData buildPredictRow(CdsData row) { } Set allowed = new HashSet<>(contextColumns); allowed.addAll(keyNames); - Map predictRow = new HashMap<>(); - allowed.forEach( - col -> { - if (row.containsKey(col)) predictRow.put(col, row.get(col)); - }); + Map predictRow = + allowed.stream() + .filter(row::containsKey) + .collect(HashMap::new, (m, col) -> m.put(col, row.get(col)), HashMap::putAll); return CdsData.create(predictRow); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index 1170780..ec523e9 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -8,18 +8,26 @@ public interface RecommendationClient { - // Currently limited to a single prediction row. Multiple prediction rows may be supported in the - // future via a separate overload, but are ruled out at two points for now: - // (1) FioriRecommendationHandler bails out when the read returns more than one entity, - // so predictions only fire on single-entity reads. - // (2) FioriRecommendationHandler also rejects responses with more than one prediction back from - // the model, treating it as an unexpected state. - // - // @param predictionRow the single entity row to predict values for; prediction columns contain - // null for missing values that the model should fill - // @param contextRows historical rows from the same entity used as training context - // @param predictionColumns names of the columns the model should predict - // @param keyNames names of the entity's key columns, used to identify rows in the response + /** + * Predicts values for the missing columns of a single entity row. + * + *

Currently limited to a single prediction row. Multiple prediction rows may be supported in + * the future via a separate overload, but are ruled out at two points for now: + * + *

    + *
  1. {@code FioriRecommendationHandler} bails out when the read returns more than one entity, + * so predictions only fire on single-entity reads. + *
  2. {@code FioriRecommendationHandler} also rejects responses with more than one prediction + * back from the model, treating it as an unexpected state. + *
+ * + * @param predictionRow the single entity row to predict values for; prediction columns contain + * null for missing values that the model should fill + * @param contextRows historical rows from the same entity used as training context + * @param predictionColumns names of the columns the model should predict + * @param keyNames names of the entity's key columns, used to identify rows in the response + * @return the predicted values as a list of result rows + */ List predict( CdsData predictionRow, List contextRows, diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 6bb189f..ee9e3a6 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -17,6 +17,7 @@ import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -62,7 +63,7 @@ public List predict( List keyNames) { String indexColumn = resolveIndexColumn(keyNames, predictionRow); CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); - List allRows = new java.util.ArrayList<>(contextRows); + List allRows = new ArrayList<>(contextRows); allRows.add(preparedPredictRow); addSyntheticKeyIfNeeded(allRows, keyNames, indexColumn); From 5ffc32974ace21a65e0e478dae40c3ca8b2decab Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 12:53:47 +0200 Subject: [PATCH 30/33] Remove reflection tests for resolveIndexColumn The index column resolution is also tested by nonIdKey_usesSyntheticKeyColumn and composedKeys_usesSyntheticKeyColumn in FioriRecommendationHandlerTest. --- .../api/RptInferenceClientTest.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index 5784e21..8abce82 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,47 +5,12 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.CdsData; -import java.lang.reflect.Method; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; class RptInferenceClientTest { - private static String resolveIndexColumn(List keyNames, CdsData sampleRow) - throws Exception { - Method m = - RptInferenceClient.class.getDeclaredMethod("resolveIndexColumn", List.class, CdsData.class); - m.setAccessible(true); - return (String) m.invoke(null, keyNames, sampleRow); - } - - @Test - void resolveIndexColumn_singleStringKey_usesItDirectly() throws Exception { - CdsData row = CdsData.create(Map.of("isbn", "978-3-16")); - assertThat(resolveIndexColumn(List.of("isbn"), row)).isEqualTo("isbn"); - } - - @Test - void resolveIndexColumn_singleUuidKey_usesItDirectly() throws Exception { - CdsData row = CdsData.create(Map.of("ID", "a009c640-434a-4542-ac68-51b400c880ec")); - assertThat(resolveIndexColumn(List.of("ID"), row)).isEqualTo("ID"); - } - - @Test - void resolveIndexColumn_singleIntegerKey_usesSyntheticColumn() throws Exception { - CdsData row = CdsData.create(Map.of("order_ID", 42)); - assertThat(resolveIndexColumn(List.of("order_ID"), row)).isEqualTo("SAP_RECOMMENDATIONS_ID"); - } - - @Test - void resolveIndexColumn_compositeKey_usesSyntheticColumn() throws Exception { - CdsData row = CdsData.create(Map.of("order_ID", 1, "item_no", 10)); - assertThat(resolveIndexColumn(List.of("order_ID", "item_no"), row)) - .isEqualTo("SAP_RECOMMENDATIONS_ID"); - } - @Test void computeSyntheticKey_singleKey() { String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); From a15dbc2eec436956b4999866281d9656f5d8d840 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 13:41:50 +0200 Subject: [PATCH 31/33] Extract RptIndexColumns utility to share index column logic between RptInferenceClient and MockRecommendationClient --- .../MockRecommendationClient.java | 2 +- .../recommendation/RptIndexColumns.java | 25 +++++++++++++++++++ .../api/RptInferenceClient.java | 22 +++------------- 3 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index cb2b64c..f37670a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -25,7 +25,7 @@ public List predict( List contextRows, List predictionColumns, List keyNames) { - String indexColumn = keyNames.size() == 1 ? keyNames.get(0) : "SAP_RECOMMENDATIONS_ID"; + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); Map prediction = new HashMap<>(); for (String col : predictionColumns) { if (predictionRow.get(col) == null) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java new file mode 100644 index 0000000..26bc7a4 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java @@ -0,0 +1,25 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import java.util.List; + +public class RptIndexColumns { + + // RPT-1 requires a single string index column to identify rows in the request/response. + // When the entity has a composite or non-string key, a synthetic string column is used instead. + public static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; + + // Returns the column name to use as the RPT-1 index column. Uses the single key directly if + // it holds a String value; falls back to the synthetic column for composite or non-string keys. + public static String resolveIndexColumn(List keyNames, CdsData sampleRow) { + if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { + return keyNames.get(0); + } + return SYNTHETIC_INDEX_COLUMN; + } + + private RptIndexColumns() {} +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index ee9e3a6..885c365 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -12,6 +12,7 @@ import com.sap.ai.sdk.foundationmodels.rpt.generated.model.RowsInnerValue; import com.sap.ai.sdk.foundationmodels.rpt.generated.model.TargetColumnConfig; import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.RptIndexColumns; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; @@ -61,7 +62,7 @@ public List predict( List contextRows, List predictionColumns, List keyNames) { - String indexColumn = resolveIndexColumn(keyNames, predictionRow); + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new ArrayList<>(contextRows); allRows.add(preparedPredictRow); @@ -85,21 +86,6 @@ public List predict( .get(); } - // RPT-1 specific: when the entity has a composite or non-ID key, a synthetic string index column - // is computed by concatenating all key fields and injected into each row before sending. - private static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; - - // If there is one string-typed key, use it directly; for composite keys or non-string keys a - // synthetic string column is needed since RPT-1 requires a single string index column. - // Non-string single keys fall back to synthetic rather than just converting to a string. - // RPT-1 may reject a column declared as the index if its values are not strings. - private static String resolveIndexColumn(List keyNames, CdsData sampleRow) { - if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { - return keyNames.get(0); - } - return SYNTHETIC_INDEX_COLUMN; - } - // '\0' is used as separator because it cannot appear in database string values // (VARCHAR/NVARCHAR), so concatenation of any composite key values is guaranteed collision-free. static String computeSyntheticKey(Map row, List keyNames) { @@ -115,8 +101,8 @@ static String computeSyntheticKey(Map row, List keyNames private static void addSyntheticKeyIfNeeded( List rows, List keyNames, String indexColumn) { - if (SYNTHETIC_INDEX_COLUMN.equals(indexColumn)) { - rows.forEach(r -> r.put(SYNTHETIC_INDEX_COLUMN, computeSyntheticKey(r, keyNames))); + if (RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn)) { + rows.forEach(r -> r.put(RptIndexColumns.SYNTHETIC_INDEX_COLUMN, computeSyntheticKey(r, keyNames))); } } From eab9c039509c31ea1616e6d19114e89da663ffaf Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 13:57:10 +0200 Subject: [PATCH 32/33] Add synthetic Key (if needed) in the sdkRow creation --- .../api/RptInferenceClient.java | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 885c365..259b0bf 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -66,9 +66,8 @@ public List predict( CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new ArrayList<>(contextRows); allRows.add(preparedPredictRow); - addSyntheticKeyIfNeeded(allRows, keyNames, indexColumn); - PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn); + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn, keyNames); logger.debug( "Sending prediction request for one row with {} context rows, {} target columns", contextRows.size(), @@ -99,13 +98,6 @@ static String computeSyntheticKey(Map row, List keyNames return sb.toString(); } - private static void addSyntheticKeyIfNeeded( - List rows, List keyNames, String indexColumn) { - if (RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn)) { - rows.forEach(r -> r.put(RptIndexColumns.SYNTHETIC_INDEX_COLUMN, computeSyntheticKey(r, keyNames))); - } - } - // Returns a copy of the predictRow with a prediction placeholder replacing empty values // in the predictionColumns - these will get filled by the predict method. private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { @@ -117,7 +109,10 @@ private static CdsData preparePredictRow(CdsData predictRow, List predic } private static PredictRequestPayload buildRequest( - List rows, List predictionColumns, String indexColumn) { + List rows, + List predictionColumns, + String indexColumn, + List keyNames) { var targetColumns = predictionColumns.stream() .map( @@ -128,7 +123,24 @@ private static PredictRequestPayload buildRequest( .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); - var sdkRows = rows.stream().map(row -> toSdkRow(row)).toList(); + // RPT-1 requires exactly one string-typed index column per row to identify predictions. + // When the entity key is composite or non-string, then the index column is + // RptIndexColumns.SYNTHETIC_INDEX_COLUMN and we need to compute the sytheticKey for all rows + // before sending them to RPT-1. + boolean syntheticKeyNeeded = RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn); + var sdkRows = + rows.stream() + .map( + row -> { + Map sdkRow = toSdkRow(row); + if (syntheticKeyNeeded) { + sdkRow.put( + RptIndexColumns.SYNTHETIC_INDEX_COLUMN, + RowsInnerValue.create(computeSyntheticKey(row, keyNames))); + } + return sdkRow; + }) + .toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) From ffca1a2db6e1d888a758b8473bdd6475745a8257 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 14:37:38 +0200 Subject: [PATCH 33/33] =?UTF-8?q?Refactor:=20remove=20keyNames=20from=20Re?= =?UTF-8?q?commendationClient=20interface=20=E2=80=94=20it=20is=20now=20an?= =?UTF-8?q?=20argument=20of=20the=20Resolver=20via=20RecommendationClientR?= =?UTF-8?q?esolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FioriRecommendationHandler.java | 13 ++++-------- .../MockRecommendationClient.java | 10 ++++++---- .../RecommendationConfiguration.java | 18 ++++++++++------- .../api/RecommendationClient.java | 6 +----- .../api/RecommendationClientResolver.java | 12 +++++------ .../api/RptInferenceClient.java | 13 ++++++------ .../FioriRecommendationHandlerTest.java | 20 +++++++------------ .../handlers/AICoreShowcaseHandler.java | 6 +++--- 8 files changed, 44 insertions(+), 54 deletions(-) diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 7a539ff..1498d0f 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -6,7 +6,6 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.reflect.CdsStructuredType; @@ -31,8 +30,7 @@ class FioriRecommendationHandler implements EventHandler { private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; - private final AICoreService aiCoreService; - private final RecommendationClientResolver clientResolver; + private final RecommendationClientResolver> clientResolver; private final PersistenceService db; private final RecommendationResultParser resultParser = new RecommendationResultParser(); // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction @@ -42,10 +40,7 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, - RecommendationClientResolver clientResolver, - PersistenceService db) { - this.aiCoreService = aiCoreService; + RecommendationClientResolver> clientResolver, PersistenceService db) { this.clientResolver = clientResolver; this.db = db; } @@ -134,9 +129,9 @@ public void afterRead(CdsReadEventContext context, List dataList) { List missingPredictionElementNames = builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); - RecommendationClient client = clientResolver.resolve(aiCoreService); + RecommendationClient client = clientResolver.resolve(builder.keyNames()); List predictions = - client.predict(predictRow, contextRows, missingPredictionElementNames, builder.keyNames()); + client.predict(predictRow, contextRows, missingPredictionElementNames); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index f37670a..c2c9157 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -18,13 +18,15 @@ class MockRecommendationClient implements RecommendationClient { // We use random here so you can see a difference in the UI. The actual value returned here is not // relevant for tests. private final Random random = new Random(); + private final List keyNames; + + MockRecommendationClient(List keyNames) { + this.keyNames = keyNames; + } @Override public List predict( - CdsData predictionRow, - List contextRows, - List predictionColumns, - List keyNames) { + CdsData predictionRow, List contextRows, List predictionColumns) { String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); Map prediction = new HashMap<>(); for (String col : predictionColumns) { diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index e31a497..529c7cf 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -14,6 +14,7 @@ import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,13 +45,15 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } boolean hasBind = hasAICoreBinding(runtime); - RecommendationClientResolver resolver = + // The real resolver is a lambda resolved at prediction time. That's necessary because + // resource group and deployment ID are tenant-specific and are only available at + // prediction time from the request context. AICoreService is captured in the closure. + RecommendationClientResolver> clientResolver = hasBind - ? RecommendationConfiguration::resolveRptClient - : service -> new MockRecommendationClient(); + ? keyNames -> resolveRptClient(aiCoreService, keyNames) + : keyNames -> new MockRecommendationClient(keyNames); - FioriRecommendationHandler handler = - new FioriRecommendationHandler(aiCoreService, resolver, db); + FioriRecommendationHandler handler = new FioriRecommendationHandler(clientResolver, db); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } @@ -64,9 +67,10 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { .isPresent(); } - private static RecommendationClient resolveRptClient(AICoreService service) { + private static RecommendationClient resolveRptClient( + AICoreService service, List keyNames) { String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); + return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId), keyNames); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index ec523e9..4558ec8 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -25,12 +25,8 @@ public interface RecommendationClient { * null for missing values that the model should fill * @param contextRows historical rows from the same entity used as training context * @param predictionColumns names of the columns the model should predict - * @param keyNames names of the entity's key columns, used to identify rows in the response * @return the predicted values as a list of result rows */ List predict( - CdsData predictionRow, - List contextRows, - List predictionColumns, - List keyNames); + CdsData predictionRow, List contextRows, List predictionColumns); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index 2f83755..82b2eca 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -3,12 +3,12 @@ */ package com.sap.cds.feature.recommendation.api; -import com.sap.cds.feature.aicore.api.AICoreService; - -// The annotation @FunctionalInterface ensures this interface has only one method, such that -// callers can supply a custom client by providing this one method e.g. via a lambda. +// A single-method interface so callers can supply a custom client via lambda. +// @FunctionalInterface enforces this and causes a compile error if a second method is ever added. +// The type parameter T allows the resolver to receive any context the client might need (e.g. key +// names). @FunctionalInterface -public interface RecommendationClientResolver { +public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService); + RecommendationClient resolve(T context); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 259b0bf..c8058e6 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -36,8 +36,8 @@ * AICoreService service = ...; * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"), List.of("ID")); + * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId), keyNames); + * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn")); * } */ public class RptInferenceClient implements RecommendationClient { @@ -50,18 +50,17 @@ public class RptInferenceClient implements RecommendationClient { private static final Retry INFERENCE_RETRY = buildInferenceRetry(); private final DefaultApi rpt; + private final List keyNames; - public RptInferenceClient(ApiClient apiClient) { + public RptInferenceClient(ApiClient apiClient, List keyNames) { this.rpt = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); + this.keyNames = keyNames; } @Override public List predict( - CdsData predictionRow, - List contextRows, - List predictionColumns, - List keyNames) { + CdsData predictionRow, List contextRows, List predictionColumns) { String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); List allRows = new ArrayList<>(contextRows); 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 48a8c25..c21c823 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 @@ -13,7 +13,6 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.ResultBuilder; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.Service; @@ -33,9 +32,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -44,9 +41,6 @@ class FioriRecommendationHandlerTest { private static CdsRuntime runtime; private static PersistenceService db; - @Mock(answer = Answers.CALLS_REAL_METHODS) - private AICoreService aiCoreService; - private FioriRecommendationHandler cut; private RecommendationClient predictionClient; @@ -67,7 +61,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient, db); + cut = new FioriRecommendationHandler(keyNames -> predictionClient, db); } // ── tests ────────────────────────────────────────────────────────────────── @@ -152,7 +146,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (predictionRow, contextRows, cols, keyNames) -> List.of(); + predictionClient = (predictionRow, contextRows, cols) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -166,7 +160,7 @@ void multiplePredictions_returnsEarlyWithoutRecommendations() { CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); predictionClient = - (predictionRow, contextRows, cols, idx) -> + (predictionRow, contextRows, cols) -> List.of( CdsData.create(Map.of("ID", "id-1")), CdsData.create(Map.of("ID", "id-2"))); cut.afterRead(ctx, dataList(row)); @@ -442,7 +436,7 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, keyNames) -> { + return (predictionRow, contextRows, predictionColumns) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { List available = @@ -450,14 +444,14 @@ private static RecommendationClient rptStyleClient() { Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); prediction.put(col, List.of(Map.of("prediction", val))); } - prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); + prediction.put("ID", predictionRow.get("ID")); return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (predictionRow, contextRows, predictionColumns, keyNames) -> { + return (predictionRow, contextRows, predictionColumns) -> { Map prediction = new HashMap<>(); for (String col : predictionColumns) { if (predictionRow.get(col) == null) { @@ -467,7 +461,7 @@ private static RecommendationClient randomPickClient() { prediction.put(col, List.of(Map.of("prediction", val))); } } - prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); + prediction.put("ID", predictionRow.get("ID")); return List.of(CdsData.create(prediction)); }; } diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 1cff6be..5ebfdf3 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -129,13 +129,13 @@ public void onPredictCategory(EventContext context) { AICoreService service = getAICoreService(); String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); + RptInferenceClient client = + new RptInferenceClient(service.inferenceClient(rg, deploymentId), List.of("ID")); List> results = new ArrayList<>(); for (Map product : products) { CdsData predictionRow = CdsData.create(new HashMap<>(product)); - List predictions = - client.predict(predictionRow, contextRows, List.of("category"), List.of("ID")); + List predictions = client.predict(predictionRow, contextRows, List.of("category")); for (CdsData prediction : predictions) { String id = (String) prediction.get("ID"); Object categoryObj = prediction.get("category");