diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreElements.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreElements.java new file mode 100644 index 0000000..2e2d972 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreElements.java @@ -0,0 +1,68 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +/** Element name constants for AICore CDS entities, mirroring the CDS model definitions. */ +public final class AICoreElements { + + private AICoreElements() {} + + public static final class Deployment { + + private Deployment() {} + + public static final String ID = "id"; + public static final String DEPLOYMENT_URL = "deploymentUrl"; + public static final String CONFIGURATION_ID = "configurationId"; + public static final String CONFIGURATION_NAME = "configurationName"; + public static final String EXECUTABLE_ID = "executableId"; + public static final String SCENARIO_ID = "scenarioId"; + public static final String STATUS = "status"; + public static final String STATUS_MESSAGE = "statusMessage"; + public static final String TARGET_STATUS = "targetStatus"; + public static final String LAST_OPERATION = "lastOperation"; + public static final String LATEST_RUNNING_CONFIGURATION_ID = "latestRunningConfigurationId"; + public static final String TTL = "ttl"; + public static final String CREATED_AT = "createdAt"; + public static final String MODIFIED_AT = "modifiedAt"; + public static final String SUBMISSION_TIME = "submissionTime"; + public static final String START_TIME = "startTime"; + public static final String COMPLETION_TIME = "completionTime"; + public static final String RESOURCE_GROUP = "resourceGroup"; + } + + public static final class ResourceGroup { + + private ResourceGroup() {} + + public static final String RESOURCE_GROUP_ID = "resourceGroupId"; + public static final String TENANT_ID = "tenantId"; + public static final String STATUS = "status"; + public static final String STATUS_MESSAGE = "statusMessage"; + public static final String CREATED_AT = "createdAt"; + public static final String LABELS = "labels"; + } + + public static final class Configuration { + + private Configuration() {} + + public static final String ID = "id"; + public static final String NAME = "name"; + public static final String EXECUTABLE_ID = "executableId"; + public static final String SCENARIO_ID = "scenarioId"; + public static final String CREATED_AT = "createdAt"; + public static final String PARAMETER_BINDINGS = "parameterBindings"; + public static final String INPUT_ARTIFACT_BINDINGS = "inputArtifactBindings"; + public static final String RESOURCE_GROUP = "resourceGroup"; + } + + public static final class Label { + + private Label() {} + + public static final String KEY = "key"; + public static final String VALUE = "value"; + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java index 138076e..91a3f3d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreService.java @@ -6,7 +6,6 @@ import com.sap.cds.services.cds.CqnService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import io.github.resilience4j.retry.Retry; -import java.util.Map; public interface AICoreService extends CqnService { @@ -24,16 +23,4 @@ public interface AICoreService extends CqnService { boolean isMultiTenancyEnabled(); Retry getRetry(); - - String getDefaultResourceGroup(); - - String getResourceGroupPrefix(); - - Map getTenantResourceGroupCache(); - - Map getResourceGroupDeploymentCache(); - - void clearTenantCache(String tenantId); - - String resolveResourceGroupFromKeys(Map keys); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 2c0d4b6..78e7321 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -67,6 +67,24 @@ public class AICoreServiceImpl extends AbstractCqnService implements AICoreServi private final AiCoreService sdkService; public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { + this( + name, + runtime, + multiTenancyEnabled, + new DeploymentApi(), + new ConfigurationApi(), + new ResourceGroupApi(), + new AiCoreService()); + } + + public AICoreServiceImpl( + String name, + CdsRuntime runtime, + boolean multiTenancyEnabled, + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) { super(name, runtime); this.multiTenancyEnabled = multiTenancyEnabled; CdsEnvironment env = runtime.getEnvironment(); @@ -83,10 +101,10 @@ public AICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEn this.tenantResourceGroupCache = newCache(); this.resourceGroupDeploymentCache = newCache(); this.deploymentLocks = newCache(); - this.deploymentApi = new DeploymentApi(); - this.configurationApi = new ConfigurationApi(); - this.resourceGroupApi = new ResourceGroupApi(); - this.sdkService = new AiCoreService(); + this.deploymentApi = deploymentApi; + this.configurationApi = configurationApi; + this.resourceGroupApi = resourceGroupApi; + this.sdkService = sdkService; } private static Cache newCache() { @@ -156,22 +174,18 @@ public Retry getRetry() { return retry; } - @Override public String getDefaultResourceGroup() { return defaultResourceGroup; } - @Override public String getResourceGroupPrefix() { return resourceGroupPrefix; } - @Override public Map getTenantResourceGroupCache() { return tenantResourceGroupCache.asMap(); } - @Override public Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache.asMap(); } @@ -192,7 +206,6 @@ public ResourceGroupApi getResourceGroupApi() { return resourceGroupApi; } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); @@ -205,7 +218,6 @@ public String resolveResourceGroupFromKeys(Map keys) { return resourceGroupForTenant(tenantId); } - @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); if (resourceGroupId != null) { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java index e605e15..fddd340 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -41,7 +41,8 @@ public void afterSubscribe(SubscribeEventContext context) { } catch (Exception e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to create AI Core resources for tenant: " + tenantId, + "Failed to create AI Core resources for tenant: {}", + tenantId, e); } } @@ -79,7 +80,9 @@ private void deleteResourceGroupForTenant(String tenantId) { } throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to delete AI Core resource group " + resourceGroupId + " for tenant " + tenantId, + "Failed to delete AI Core resource group {} for tenant {}", + resourceGroupId, + tenantId, e); } } @@ -103,7 +106,8 @@ private String resolveResourceGroupId(String tenantId) { } catch (OpenApiRequestException e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, - "Failed to look up AI Core resource group for tenant " + tenantId, + "Failed to look up AI Core resource group for tenant {}", + tenantId, e); } List resources = result.getResources(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java index c13a197..637b964 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java @@ -68,27 +68,22 @@ public Retry getRetry() { return retry; } - @Override public String getDefaultResourceGroup() { return defaultResourceGroup; } - @Override public String getResourceGroupPrefix() { return resourceGroupPrefix; } - @Override public Map getTenantResourceGroupCache() { return tenantResourceGroupCache; } - @Override public Map getResourceGroupDeploymentCache() { return resourceGroupDeploymentCache; } - @Override public void clearTenantCache(String tenantId) { String resourceGroupId = tenantResourceGroupCache.remove(tenantId); if (resourceGroupId != null) { @@ -99,7 +94,6 @@ public void clearTenantCache(String tenantId) { } } - @Override public String resolveResourceGroupFromKeys(Map keys) { if (keys.containsKey("resourceGroup_resourceGroupId")) { return (String) keys.get("resourceGroup_resourceGroupId"); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 3793e76..47f3858 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -5,12 +5,10 @@ import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.services.handler.EventHandler; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; -import java.util.stream.Collectors; abstract class AbstractCrudHandler implements EventHandler { @@ -34,7 +32,7 @@ protected static Map merge(Map keys, Map List mapResources(List resources, Function mapper) { - if (resources == null) return new ArrayList<>(); - return resources.stream().map(mapper).collect(Collectors.toList()); + if (resources == null) return List.of(); + return resources.stream().map(mapper).toList(); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index b811c96..b0c0490 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.aicore.core.handler; +import static com.sap.cds.feature.aicore.core.AICoreElements.Deployment; + import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; @@ -40,7 +42,7 @@ public void onResourceGroupForTenant(EventContext context) { @On(event = "stop", entity = AICoreService.DEPLOYMENTS) public void onStop(EventContext context) { Map keys = asMap(context.get("keys")); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployment.ID); String resourceGroupId = resolveResourceGroup(keys); DeploymentApi api = service.getDeploymentApi(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index d3fb8d9..4ceb5dd 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -3,6 +3,9 @@ */ package com.sap.cds.feature.aicore.core.handler; +import static com.sap.cds.feature.aicore.core.AICoreElements.Configuration; +import static com.sap.cds.feature.aicore.core.AICoreElements.Label; + import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.model.AiConfiguration; import com.sap.ai.sdk.core.model.AiConfigurationBaseData; @@ -54,12 +57,12 @@ public void onRead(CdsReadEventContext context) { keys, values); - String id = (String) keys.get("id"); + String id = (String) keys.get(Configuration.ID); if (id != null) { AiConfiguration config = configurationApi.get(resourceGroupId, id); context.setResult(List.of(toMap(config, resourceGroupId))); } else { - String scenarioId = (String) values.get("scenarioId"); + String scenarioId = (String) values.get(Configuration.SCENARIO_ID); AiConfigurationList result = configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null); List> results = @@ -77,9 +80,9 @@ public void onCreate(CdsCreateEventContext context) { for (Map entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String name = (String) entry.get("name"); - String executableId = (String) entry.get("executableId"); - String scenarioId = (String) entry.get("scenarioId"); + String name = (String) entry.get(Configuration.NAME); + String executableId = (String) entry.get(Configuration.EXECUTABLE_ID); + String scenarioId = (String) entry.get(Configuration.SCENARIO_ID); AiConfigurationBaseData request = AiConfigurationBaseData.create() @@ -89,22 +92,22 @@ public void onCreate(CdsCreateEventContext context) { @SuppressWarnings("unchecked") List> paramBindings = - (List>) entry.get("parameterBindings"); + (List>) entry.get(Configuration.PARAMETER_BINDINGS); if (paramBindings != null) { List sdkBindings = paramBindings.stream() .map( p -> AiParameterArgumentBinding.create() - .key((String) p.get("key")) - .value((String) p.get("value"))) + .key((String) p.get(Label.KEY)) + .value((String) p.get(Label.VALUE))) .toList(); request.parameterBindings(sdkBindings); } var response = configurationApi.create(resourceGroupId, request); CdsData result = CdsData.create(entry); - result.put("id", response.getId()); + result.put(Configuration.ID, response.getId()); results.add(result); logger.debug( "Created configuration {} in resource group {}", response.getId(), resourceGroupId); @@ -114,23 +117,23 @@ public void onCreate(CdsCreateEventContext context) { private CdsData toMap(AiConfiguration config, String resourceGroupId) { CdsData data = CdsData.create(); - data.put("id", config.getId()); - data.put("name", config.getName()); - data.put("executableId", config.getExecutableId()); - data.put("scenarioId", config.getScenarioId()); - data.put("createdAt", config.getCreatedAt()); + data.put(Configuration.ID, config.getId()); + data.put(Configuration.NAME, config.getName()); + data.put(Configuration.EXECUTABLE_ID, config.getExecutableId()); + data.put(Configuration.SCENARIO_ID, config.getScenarioId()); + data.put(Configuration.CREATED_AT, config.getCreatedAt()); if (config.getParameterBindings() != null) { List bindings = config.getParameterBindings().stream() .map( b -> { CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); - bm.put("value", b.getValue()); + bm.put(Label.KEY, b.getKey()); + bm.put(Label.VALUE, b.getValue()); return bm; }) .toList(); - data.put("parameterBindings", bindings); + data.put(Configuration.PARAMETER_BINDINGS, bindings); } if (config.getInputArtifactBindings() != null) { List bindings = @@ -138,12 +141,12 @@ private CdsData toMap(AiConfiguration config, String resourceGroupId) { .map( b -> { CdsData bm = CdsData.create(); - bm.put("key", b.getKey()); + bm.put(Label.KEY, b.getKey()); bm.put("artifactId", b.getArtifactId()); return bm; }) .toList(); - data.put("inputArtifactBindings", bindings); + data.put(Configuration.INPUT_ARTIFACT_BINDINGS, bindings); } data.putPath("resourceGroup.resourceGroupId", resourceGroupId); return data; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index f1e6c76..40352ef 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.aicore.core.handler; +import static com.sap.cds.feature.aicore.core.AICoreElements.Deployment; + import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeployment; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; @@ -57,14 +59,15 @@ public void onRead(CdsReadEventContext context) { String resourceGroupId = resolveResourceGroup(merge(keys, values)); - String id = (String) keys.get("id"); + String id = (String) keys.get(Deployment.ID); if (id != null) { AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); - context.setResult(List.of(toMap(deployment, resourceGroupId))); + context.setResult(List.of(toCdsData(extractFields(deployment), resourceGroupId))); } else { AiDeploymentList result = deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); - context.setResult(mapResources(result.getResources(), d -> toMap(d, resourceGroupId))); + context.setResult( + mapResources(result.getResources(), d -> toCdsData(extractFields(d), resourceGroupId))); } } @@ -76,19 +79,19 @@ public void onCreate(CdsCreateEventContext context) { for (Map entry : entries) { String resourceGroupId = resolveResourceGroup(entry); - String configurationId = (String) entry.get("configurationId"); + String configurationId = (String) entry.get(Deployment.CONFIGURATION_ID); AiDeploymentCreationRequest request = AiDeploymentCreationRequest.create().configurationId(configurationId); - if (entry.containsKey("ttl")) { - request.ttl((String) entry.get("ttl")); + if (entry.containsKey(Deployment.TTL)) { + request.ttl((String) entry.get(Deployment.TTL)); } var response = deploymentApi.create(resourceGroupId, request); CdsData result = CdsData.create(entry); - result.put("id", response.getId()); - result.put("status", response.getStatus().getValue()); + result.put(Deployment.ID, response.getId()); + result.put(Deployment.STATUS, response.getStatus().getValue()); results.add(result); logger.debug("Created deployment {} in resource group {}", response.getId(), resourceGroupId); } @@ -103,7 +106,8 @@ public void onUpdate(CdsUpdateEventContext context) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); } Map data = entries.get(0); - if (!data.containsKey("targetStatus") && !data.containsKey("configurationId")) { + if (!data.containsKey(Deployment.TARGET_STATUS) + && !data.containsKey(Deployment.CONFIGURATION_ID)) { throw new ServiceException( ErrorStatuses.BAD_REQUEST, "Update payload must contain 'targetStatus' or 'configurationId'"); @@ -113,17 +117,17 @@ public void onUpdate(CdsUpdateEventContext context) { CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(update).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployment.ID); String resourceGroupId = resolveResourceGroup(merge(keys, data)); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); - if (data.containsKey("targetStatus")) { - String targetStatus = (String) data.get("targetStatus"); + if (data.containsKey(Deployment.TARGET_STATUS)) { + String targetStatus = (String) data.get(Deployment.TARGET_STATUS); modRequest.targetStatus(AiDeploymentTargetStatus.fromValue(targetStatus)); } - if (data.containsKey("configurationId")) { - modRequest.configurationId((String) data.get("configurationId")); + if (data.containsKey(Deployment.CONFIGURATION_ID)) { + modRequest.configurationId((String) data.get(Deployment.CONFIGURATION_ID)); } deploymentApi.modify(resourceGroupId, deploymentId, modRequest); @@ -138,7 +142,7 @@ public void onDelete(CdsDeleteEventContext context) { CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(delete).targetKeys(); - String deploymentId = (String) keys.get("id"); + String deploymentId = (String) keys.get(Deployment.ID); String resourceGroupId = resolveResourceGroup(keys); deploymentApi.delete(resourceGroupId, deploymentId); @@ -146,9 +150,28 @@ public void onDelete(CdsDeleteEventContext context) { context.setResult(List.of()); } + private record DeploymentFields( + String id, + String deploymentUrl, + String configurationId, + String configurationName, + String executableId, + String scenarioId, + String status, + String statusMessage, + String targetStatus, + String lastOperation, + String latestRunningConfigurationId, + String ttl, + Object createdAt, + Object modifiedAt, + Object submissionTime, + Object startTime, + Object completionTime) {} + // CPD-OFF - SDK types AiDeploymentResponseWithDetails and AiDeployment share no common interface - private CdsData toMap(AiDeploymentResponseWithDetails d, String resourceGroupId) { - return buildDeploymentData( + private static DeploymentFields extractFields(AiDeploymentResponseWithDetails d) { + return new DeploymentFields( d.getId(), d.getDeploymentUrl(), d.getConfigurationId(), @@ -165,12 +188,11 @@ private CdsData toMap(AiDeploymentResponseWithDetails d, String resourceGroupId) d.getModifiedAt(), d.getSubmissionTime(), d.getStartTime(), - d.getCompletionTime(), - resourceGroupId); + d.getCompletionTime()); } - private CdsData toMap(AiDeployment d, String resourceGroupId) { - return buildDeploymentData( + private static DeploymentFields extractFields(AiDeployment d) { + return new DeploymentFields( d.getId(), d.getDeploymentUrl(), d.getConfigurationId(), @@ -187,49 +209,30 @@ private CdsData toMap(AiDeployment d, String resourceGroupId) { d.getModifiedAt(), d.getSubmissionTime(), d.getStartTime(), - d.getCompletionTime(), - resourceGroupId); + d.getCompletionTime()); } // CPD-ON - private static CdsData buildDeploymentData( - String id, - String deploymentUrl, - String configurationId, - String configurationName, - String executableId, - String scenarioId, - String status, - String statusMessage, - String targetStatus, - String lastOperation, - String latestRunningConfigurationId, - String ttl, - Object createdAt, - Object modifiedAt, - Object submissionTime, - Object startTime, - Object completionTime, - String resourceGroupId) { + private static CdsData toCdsData(DeploymentFields f, String resourceGroupId) { CdsData data = CdsData.create(); - data.put("id", id); - data.put("deploymentUrl", deploymentUrl); - data.put("configurationId", configurationId); - data.put("configurationName", configurationName); - data.put("executableId", executableId); - data.put("scenarioId", scenarioId); - data.put("status", status); - data.put("statusMessage", statusMessage); - data.put("targetStatus", targetStatus); - data.put("lastOperation", lastOperation); - data.put("latestRunningConfigurationId", latestRunningConfigurationId); - data.put("ttl", ttl); - data.put("createdAt", createdAt); - data.put("modifiedAt", modifiedAt); - data.put("submissionTime", submissionTime); - data.put("startTime", startTime); - data.put("completionTime", completionTime); + data.put(Deployment.ID, f.id()); + data.put(Deployment.DEPLOYMENT_URL, f.deploymentUrl()); + data.put(Deployment.CONFIGURATION_ID, f.configurationId()); + data.put(Deployment.CONFIGURATION_NAME, f.configurationName()); + data.put(Deployment.EXECUTABLE_ID, f.executableId()); + data.put(Deployment.SCENARIO_ID, f.scenarioId()); + data.put(Deployment.STATUS, f.status()); + data.put(Deployment.STATUS_MESSAGE, f.statusMessage()); + data.put(Deployment.TARGET_STATUS, f.targetStatus()); + data.put(Deployment.LAST_OPERATION, f.lastOperation()); + data.put(Deployment.LATEST_RUNNING_CONFIGURATION_ID, f.latestRunningConfigurationId()); + data.put(Deployment.TTL, f.ttl()); + data.put(Deployment.CREATED_AT, f.createdAt()); + data.put(Deployment.MODIFIED_AT, f.modifiedAt()); + data.put(Deployment.SUBMISSION_TIME, f.submissionTime()); + data.put(Deployment.START_TIME, f.startTime()); + data.put(Deployment.COMPLETION_TIME, f.completionTime()); data.putPath("resourceGroup.resourceGroupId", resourceGroupId); return data; } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 1037b68..c93fca5 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -3,6 +3,9 @@ */ package com.sap.cds.feature.aicore.core.handler; +import static com.sap.cds.feature.aicore.core.AICoreElements.Label; +import static com.sap.cds.feature.aicore.core.AICoreElements.ResourceGroup; + import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; @@ -53,9 +56,9 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = (String) keys.get("resourceGroupId"); + String resourceGroupId = (String) keys.get(ResourceGroup.RESOURCE_GROUP_ID); if (resourceGroupId == null) { - resourceGroupId = (String) values.get("resourceGroupId"); + resourceGroupId = (String) values.get(ResourceGroup.RESOURCE_GROUP_ID); } if (resourceGroupId != null) { @@ -63,8 +66,8 @@ public void onRead(CdsReadEventContext context) { context.setResult(List.of(toMap(rg))); } else { List labelSelector = null; - if (values.containsKey("tenantId")) { - String tenantId = (String) values.get("tenantId"); + if (values.containsKey(ResourceGroup.TENANT_ID)) { + String tenantId = (String) values.get(ResourceGroup.TENANT_ID); labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); } BckndResourceGroupList result = @@ -80,12 +83,13 @@ public void onCreate(CdsCreateEventContext context) { List> results = new ArrayList<>(); for (Map entry : entries) { - String resourceGroupId = (String) entry.get("resourceGroupId"); + String resourceGroupId = (String) entry.get(ResourceGroup.RESOURCE_GROUP_ID); BckndResourceGroupsPostRequest request = BckndResourceGroupsPostRequest.create().resourceGroupId(resourceGroupId); @SuppressWarnings("unchecked") - List> labels = (List>) entry.get("labels"); + List> labels = + (List>) entry.get(ResourceGroup.LABELS); List mergedLabels = new ArrayList<>(); // User-supplied labels take precedence: if they include the tenant label key, we skip @@ -93,10 +97,10 @@ public void onCreate(CdsCreateEventContext context) { boolean userSuppliedTenantLabel = labels != null && labels.stream() - .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); + .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get(Label.KEY))); - if (entry.containsKey("tenantId") && !userSuppliedTenantLabel) { - String tenantId = (String) entry.get("tenantId"); + if (entry.containsKey(ResourceGroup.TENANT_ID) && !userSuppliedTenantLabel) { + String tenantId = (String) entry.get(ResourceGroup.TENANT_ID); mergedLabels.add( BckndResourceGroupLabel.create() .key(AICoreServiceImpl.TENANT_LABEL_KEY) @@ -131,7 +135,7 @@ public void onUpdate(CdsUpdateEventContext context) { BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @SuppressWarnings("unchecked") - List> labels = (List>) data.get("labels"); + List> labels = (List>) data.get(ResourceGroup.LABELS); if (labels != null) { patchRequest.labels(toSdkLabels(labels)); } @@ -155,11 +159,11 @@ public void onDelete(CdsDeleteEventContext context) { } private String resolveResourceGroupId(Map keys) { - if (keys.containsKey("resourceGroupId")) { - return (String) keys.get("resourceGroupId"); + if (keys.containsKey(ResourceGroup.RESOURCE_GROUP_ID)) { + return (String) keys.get(ResourceGroup.RESOURCE_GROUP_ID); } - if (keys.containsKey("tenantId")) { - return service.resourceGroupForTenant((String) keys.get("tenantId")); + if (keys.containsKey(ResourceGroup.TENANT_ID)) { + return service.resourceGroupForTenant((String) keys.get(ResourceGroup.TENANT_ID)); } return service.getDefaultResourceGroup(); } @@ -169,29 +173,29 @@ private static List toSdkLabels(List BckndResourceGroupLabel.create() - .key((String) l.get("key")) - .value((String) l.get("value"))) + .key((String) l.get(Label.KEY)) + .value((String) l.get(Label.VALUE))) .toList(); } private CdsData toMap(BckndResourceGroup rg) { CdsData data = CdsData.create(); - data.put("resourceGroupId", rg.getResourceGroupId()); - data.put("status", rg.getStatus().getValue()); - data.put("statusMessage", rg.getStatusMessage()); - data.put("createdAt", rg.getCreatedAt()); + data.put(ResourceGroup.RESOURCE_GROUP_ID, rg.getResourceGroupId()); + data.put(ResourceGroup.STATUS, rg.getStatus().getValue()); + data.put(ResourceGroup.STATUS_MESSAGE, rg.getStatusMessage()); + data.put(ResourceGroup.CREATED_AT, rg.getCreatedAt()); if (rg.getLabels() != null) { List labels = new ArrayList<>(rg.getLabels().size()); for (BckndResourceGroupLabel l : rg.getLabels()) { CdsData lm = CdsData.create(); - lm.put("key", l.getKey()); - lm.put("value", l.getValue()); + lm.put(Label.KEY, l.getKey()); + lm.put(Label.VALUE, l.getValue()); labels.add(lm); if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) { - data.put("tenantId", l.getValue()); + data.put(ResourceGroup.TENANT_ID, l.getValue()); } } - data.put("labels", labels); + data.put(ResourceGroup.LABELS, labels); } return data; } 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 46aef51..2af07ba 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 @@ -3,17 +3,10 @@ */ package com.sap.cds.feature.recommendation; -import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; - +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.Result; import com.sap.cds.feature.aicore.core.AICoreService; -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.Select; -import com.sap.cds.reflect.CdsAssociationType; -import com.sap.cds.reflect.CdsBaseType; -import com.sap.cds.reflect.CdsElement; -import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsReadEventContext; @@ -23,57 +16,23 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.utils.DraftUtils; -import java.math.BigDecimal; import java.util.ArrayList; -import java.util.EnumSet; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = ApplicationService.class) class FioriRecommendationHandler implements EventHandler { - private final AICoreService aiCoreService; - private final RecommendationClientResolver clientResolver; - private final Set entitiesWithoutPredictions = ConcurrentHashMap.newKeySet(); private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); - private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; - private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = - "@Common.ValueListWithFixedValues"; private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; - private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; - private static final Set SUPPORTED_CONTEXT_TYPES = - EnumSet.of( - CdsBaseType.STRING, - CdsBaseType.LARGE_STRING, - CdsBaseType.UUID, - CdsBaseType.BOOLEAN, - CdsBaseType.INTEGER, - CdsBaseType.UINT8, - CdsBaseType.INT16, - CdsBaseType.INT32, - CdsBaseType.INT64, - CdsBaseType.INTEGER64, - CdsBaseType.DECIMAL, - CdsBaseType.DOUBLE, - CdsBaseType.DATE, - CdsBaseType.TIME, - CdsBaseType.DATETIME, - CdsBaseType.TIMESTAMP, - CdsBaseType.HANA_SMALLINT, - CdsBaseType.HANA_TINYINT, - CdsBaseType.HANA_SMALLDECIMAL, - CdsBaseType.HANA_REAL, - CdsBaseType.HANA_CHAR, - CdsBaseType.HANA_NCHAR, - CdsBaseType.HANA_VARCHAR, - CdsBaseType.HANA_CLOB); + + private final AICoreService aiCoreService; + private final RecommendationClientResolver clientResolver; + private final RecommendationResultParser resultParser = new RecommendationResultParser(); + private final Cache entitiesWithoutPredictions = + Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( AICoreService aiCoreService, RecommendationClientResolver clientResolver) { @@ -88,7 +47,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } String entityName = target.getQualifiedName(); - if (entitiesWithoutPredictions.contains(entityName)) { + if (entitiesWithoutPredictions.getIfPresent(entityName) != null) { return; } @@ -110,47 +69,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { if (rowType == null) { rowType = target; } - List predictionElementNames = - rowType - .elements() - .filter( - byAnnotation(VALUE_LIST_ANNOTATION) - .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) - .filter(e -> !e.getType().isAssociation()) - .map(CdsElement::getName) - .toList(); - if (predictionElementNames.isEmpty()) { - entitiesWithoutPredictions.add(entityName); - return; - } - List contextColumns = - rowType - .concreteNonAssociationElements() - .filter(e -> e.getType().isSimple()) - .filter( - e -> - SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) - .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) - .map(CdsElement::getName) - .toList(); - if (contextColumns.isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); - return; - } - - List keyNames = target.keyElements().map(CdsElement::getName).toList(); - boolean syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - String indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); - - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } int limit = context .getCdsRuntime() @@ -159,52 +78,41 @@ public void afterRead(CdsReadEventContext context, List dataList) { "cds.requires.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); - var select = - Select.from(target.getQualifiedName()) - .columns(selectColumns.toArray(String[]::new)) - .where( - predictionElementNames.stream() - .map(col -> CQL.get(col).isNotNull()) - .collect(CQL.withAnd())) - .limit(limit); - target - .concreteNonAssociationElements() - .filter(byAnnotation("cds.on.update")) - .map(CdsElement::getName) - .findFirst() - .or(() -> target.keyElements().map(CdsElement::getName).findFirst()) - .ifPresent(col -> select.orderBy(CQL.get(col).desc())); + + var builder = new RecommendationContextBuilder(target, rowType, limit); + + if (builder.predictionElementNames().isEmpty()) { + entitiesWithoutPredictions.put(entityName, Boolean.TRUE); + return; + } + + if (builder.contextColumns().isEmpty()) { + logger.debug("No suitable context columns found, skipping predictions."); + return; + } PersistenceService db = context .getServiceCatalog() .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - List contextRows = new ArrayList<>(db.run(select).list()); + 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 = buildPredictRow(row, predictionElementNames); + CdsData predictRow = builder.buildPredictRow(row); if (predictRow == null) { + logger.debug("Current row already has values for all prediction columns, skipping."); return; } - List allRows = new ArrayList<>(); - if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow, keyNames)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(row, keyNames)); - } else { - allRows.addAll(contextRows); - } - allRows.add(predictRow); + List allRows = builder.assembleRows(contextRows, predictRow, row); String tenantId = context.getUserInfo().getTenant(); RecommendationClient client = clientResolver.resolve(aiCoreService, tenantId); - List predictions = client.predict(allRows, predictionElementNames, indexColumn); + List predictions = + client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -216,188 +124,10 @@ public void afterRead(CdsReadEventContext context, List dataList) { } List missingPredictionElementNames = - predictionElementNames.stream().filter(c -> row.get(c) == null).toList(); + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = - buildRecommendations(db, predictions.get(0), missingPredictionElementNames, context, rowType); + resultParser.buildRecommendations( + db, predictions.get(0), missingPredictionElementNames, context, rowType); row.put("SAP_Recommendations", recommendations); } - - private CdsData buildPredictRow(CdsData row, List predictionElementNames) { - if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { - logger.debug("Current row already has values for all prediction columns, skipping."); - return null; - } - Map predictRow = new HashMap<>(row); - Drafts.ELEMENTS.forEach(predictRow::remove); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); - } - return CdsData.create(predictRow); - } - - private 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)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - private Map buildRecommendations( - PersistenceService db, - CdsData prediction, - List predictionElementNames, - CdsReadEventContext context, - CdsStructuredType rowType) { - Map textPaths = resolveTextPaths(predictionElementNames, context); - - Map parsedValues = new HashMap<>(); - for (String col : predictionElementNames) { - Object obj = prediction.get(col); - if (!(obj instanceof List list) - || list.isEmpty() - || !(list.get(0) instanceof Map map)) { - continue; - } - CdsBaseType baseType = - rowType - .findElement(col) - .filter(e -> e.getType().isSimple()) - .map(e -> e.getType().as(CdsSimpleType.class).getType()) - .orElse(CdsBaseType.STRING); - parsedValues.put(col, parseValue(map.get("prediction"), baseType)); - } - - Map descriptions = - resolveDescriptionsBatch(db, parsedValues, textPaths, context); - - Map recommendations = new HashMap<>(); - for (Map.Entry entry : parsedValues.entrySet()) { - String col = entry.getKey(); - Object recommendedValue = entry.getValue(); - Map values = new HashMap<>(); - values.put("RecommendedFieldValue", recommendedValue); - values.put("RecommendedFieldDescription", descriptions.getOrDefault(col, "")); - values.put("RecommendedFieldScoreValue", 0.5); - values.put("RecommendedFieldIsSuggestion", true); - recommendations.put(col, List.of(values)); - } - return recommendations; - } - - private Map resolveTextPaths( - List predictionElementNames, CdsReadEventContext context) { - CdsStructuredType target = context.getTarget(); - Map fkToAssociation = buildFkToAssociationMap(target); - Map textPaths = new HashMap<>(); - for (String col : predictionElementNames) { - Optional path; - String assocName = fkToAssociation.get(col); - if (assocName != null) { - path = getTextPath(context, assocName); - if (path.isEmpty()) { - path = getTextPath(context, col); - } - } else { - path = getTextPath(context, col); - } - path.ifPresent(p -> textPaths.put(col, p)); - } - return textPaths; - } - - private Map buildFkToAssociationMap(CdsStructuredType target) { - Map map = new HashMap<>(); - target - .associations() - .forEach( - assocElement -> { - CdsAssociationType assocType = assocElement.getType().as(CdsAssociationType.class); - String assocName = assocElement.getName(); - assocType - .refs() - .forEach(ref -> map.put(assocName + "_" + ref.lastSegment(), assocName)); - }); - return map; - } - - private Object parseValue(Object value, CdsBaseType baseType) { - if (value == null) { - return null; - } - String s = value.toString(); - try { - return switch (baseType) { - case INTEGER, INT16, INT32, UINT8, HANA_SMALLINT, HANA_TINYINT -> Integer.valueOf(s); - case INT64, INTEGER64 -> Long.valueOf(s); - case DECIMAL, DECIMAL_FLOAT, HANA_SMALLDECIMAL -> new BigDecimal(s); - case DOUBLE, HANA_REAL -> Double.valueOf(s); - case BOOLEAN -> Boolean.valueOf(s); - default -> s; - }; - } catch (NumberFormatException e) { - return s; - } - } - - private Map resolveDescriptionsBatch( - PersistenceService db, - Map parsedValues, - Map textPaths, - CdsReadEventContext context) { - Map descriptions = new HashMap<>(); - if (textPaths.isEmpty()) { - return descriptions; - } - String entity = context.getTarget().getQualifiedName(); - for (Map.Entry entry : parsedValues.entrySet()) { - String col = entry.getKey(); - String path = textPaths.get(col); - if (path == null) { - continue; - } - String[] parts = path.split("\\."); - if (parts.length != 2) { - logger.debug( - "Text path {} for column {} is not in expected format 'association.textField'.", - path, - col); - continue; - } - Result r = - db.run( - Select.from(entity) - .columns(b -> b.to(parts[0]).get(parts[1]).as("desc")) - .where(CQL.get(col).eq(entry.getValue())) - .limit(1)); - r.first() - .map(row -> row.get("desc")) - .filter(Objects::nonNull) - .ifPresent(d -> descriptions.put(col, d.toString())); - } - return descriptions; - } - - private Optional getTextPath(CdsReadEventContext context, String columnName) { - return context - .getTarget() - .findElement(columnName) - .flatMap(e -> e.findAnnotation("@Common.Text")) - .flatMap( - a -> { - Object val = a.getValue(); - if (val instanceof String s) return Optional.of(s); - if (val instanceof Map m && m.get("=") != null) - return Optional.of(m.get("=").toString()); - return Optional.empty(); - }); - } } 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 new file mode 100644 index 0000000..021a6a3 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -0,0 +1,188 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import static com.sap.cds.reflect.CdsAnnotatable.byAnnotation; + +import com.sap.cds.CdsData; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsElement; +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.List; +import java.util.Map; +import java.util.Set; + +/** + * 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. + */ +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, + CdsBaseType.LARGE_STRING, + CdsBaseType.UUID, + CdsBaseType.BOOLEAN, + CdsBaseType.INTEGER, + CdsBaseType.UINT8, + CdsBaseType.INT16, + CdsBaseType.INT32, + CdsBaseType.INT64, + CdsBaseType.INTEGER64, + CdsBaseType.DECIMAL, + CdsBaseType.DOUBLE, + CdsBaseType.DATE, + CdsBaseType.TIME, + CdsBaseType.DATETIME, + CdsBaseType.TIMESTAMP, + CdsBaseType.HANA_SMALLINT, + CdsBaseType.HANA_TINYINT, + CdsBaseType.HANA_SMALLDECIMAL, + CdsBaseType.HANA_REAL, + CdsBaseType.HANA_CHAR, + CdsBaseType.HANA_NCHAR, + CdsBaseType.HANA_VARCHAR, + CdsBaseType.HANA_CLOB); + + private final CdsStructuredType target; + private final CdsStructuredType rowType; + private final int contextRowLimit; + 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; + this.rowType = rowType; + this.contextRowLimit = limit; + 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() { + return predictionElementNames; + } + + List contextColumns() { + return contextColumns; + } + + String indexColumn() { + return indexColumn; + } + + boolean syntheticKeyNeeded() { + return syntheticKeyNeeded; + } + + CqnSelect buildContextQuery() { + List selectColumns = new ArrayList<>(contextColumns); + for (String key : keyNames) { + if (!selectColumns.contains(key)) { + selectColumns.add(key); + } + } + var select = + Select.from(target.getQualifiedName()) + .columns(selectColumns.toArray(String[]::new)) + .where( + predictionElementNames.stream() + .map(col -> CQL.get(col).isNotNull()) + .collect(CQL.withAnd())) + .limit(contextRowLimit); + target + .concreteNonAssociationElements() + .filter(byAnnotation("cds.on.update")) + .map(CdsElement::getName) + .findFirst() + .or(() -> target.keyElements().map(CdsElement::getName).findFirst()) + .ifPresent(col -> select.orderBy(CQL.get(col).desc())); + return select; + } + + 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); + for (String col : predictionElementNames) { + predictRow.putIfAbsent(col, "[PREDICT]"); + } + return CdsData.create(predictRow); + } + + 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(); + } + + List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { + List allRows = new ArrayList<>(); + 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); + } + allRows.add(predictRow); + return allRows; + } + + private List computePredictionElements() { + return rowType + .elements() + .filter( + byAnnotation(VALUE_LIST_ANNOTATION) + .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) + .filter(e -> !e.getType().isAssociation()) + .map(CdsElement::getName) + .toList(); + } + + private List computeContextColumns() { + return rowType + .concreteNonAssociationElements() + .filter(e -> e.getType().isSimple()) + .filter( + e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .map(CdsElement::getName) + .toList(); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java new file mode 100644 index 0000000..5685ed0 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationResultParser.java @@ -0,0 +1,182 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsBaseType; +import com.sap.cds.reflect.CdsSimpleType; +import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses AI prediction responses and assembles them into the SAP_Recommendations structure expected + * by Fiori UIs. Handles type coercion, text path resolution, and description lookups. + */ +class RecommendationResultParser { + + private static final Logger logger = LoggerFactory.getLogger(RecommendationResultParser.class); + + Map buildRecommendations( + PersistenceService db, + CdsData prediction, + List predictionElementNames, + CdsReadEventContext context, + CdsStructuredType rowType) { + Map textPaths = resolveTextPaths(predictionElementNames, context); + + Map parsedValues = new HashMap<>(); + for (String col : predictionElementNames) { + Object obj = prediction.get(col); + if (!(obj instanceof List list) + || list.isEmpty() + || !(list.get(0) instanceof Map map)) { + continue; + } + CdsBaseType baseType = + rowType + .findElement(col) + .filter(e -> e.getType().isSimple()) + .map(e -> e.getType().as(CdsSimpleType.class).getType()) + .orElse(CdsBaseType.STRING); + parsedValues.put(col, parseValue(map.get("prediction"), baseType)); + } + + Map descriptions = + resolveDescriptionsBatch(db, parsedValues, textPaths, context); + + Map recommendations = new HashMap<>(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + Object recommendedValue = entry.getValue(); + Map values = new HashMap<>(); + values.put("RecommendedFieldValue", recommendedValue); + values.put("RecommendedFieldDescription", descriptions.getOrDefault(col, "")); + values.put("RecommendedFieldScoreValue", 0.5); + values.put("RecommendedFieldIsSuggestion", true); + recommendations.put(col, List.of(values)); + } + return recommendations; + } + + private Object parseValue(Object value, CdsBaseType baseType) { + if (value == null) { + return null; + } + String s = value.toString(); + try { + return switch (baseType) { + case INTEGER, INT16, INT32, UINT8, HANA_SMALLINT, HANA_TINYINT -> Integer.valueOf(s); + case INT64, INTEGER64 -> Long.valueOf(s); + case DECIMAL, DECIMAL_FLOAT, HANA_SMALLDECIMAL -> new BigDecimal(s); + case DOUBLE, HANA_REAL -> Double.valueOf(s); + case BOOLEAN -> Boolean.valueOf(s); + default -> s; + }; + } catch (NumberFormatException e) { + return s; + } + } + + private Map resolveTextPaths( + List predictionElementNames, CdsReadEventContext context) { + CdsStructuredType target = context.getTarget(); + Map fkToAssociation = buildFkToAssociationMap(target); + Map textPaths = new HashMap<>(); + for (String col : predictionElementNames) { + Optional path; + String assocName = fkToAssociation.get(col); + if (assocName != null) { + path = getTextPath(context, assocName); + if (path.isEmpty()) { + path = getTextPath(context, col); + } + } else { + path = getTextPath(context, col); + } + path.ifPresent(p -> textPaths.put(col, p)); + } + return textPaths; + } + + private Map buildFkToAssociationMap(CdsStructuredType target) { + Map map = new HashMap<>(); + target + .associations() + .forEach( + assocElement -> { + CdsAssociationType assocType = assocElement.getType().as(CdsAssociationType.class); + String assocName = assocElement.getName(); + assocType + .refs() + .forEach(ref -> map.put(assocName + "_" + ref.lastSegment(), assocName)); + }); + return map; + } + + private Map resolveDescriptionsBatch( + PersistenceService db, + Map parsedValues, + Map textPaths, + CdsReadEventContext context) { + Map descriptions = new HashMap<>(); + if (textPaths.isEmpty()) { + return descriptions; + } + String entity = context.getTarget().getQualifiedName(); + for (Map.Entry entry : parsedValues.entrySet()) { + String col = entry.getKey(); + String path = textPaths.get(col); + if (path == null) { + continue; + } + String[] parts = path.split("\\."); + if (parts.length != 2) { + logger.debug( + "Text path {} for column {} is not in expected format 'association.textField'.", + path, + col); + continue; + } + Result r = + db.run( + Select.from(entity) + .columns(b -> b.to(parts[0]).get(parts[1]).as("desc")) + .where(CQL.get(col).eq(entry.getValue())) + .limit(1)); + r.first() + .map(row -> row.get("desc")) + .filter(Objects::nonNull) + .ifPresent(d -> descriptions.put(col, d.toString())); + } + return descriptions; + } + + private Optional getTextPath(CdsReadEventContext context, String columnName) { + return context + .getTarget() + .findElement(columnName) + .flatMap(e -> e.findAnnotation("@Common.Text")) + .flatMap( + a -> { + Object val = a.getValue(); + if (val instanceof String s) return Optional.of(s); + if (val instanceof Map m && m.get("=") != null) + return Optional.of(m.get("=").toString()); + return Optional.empty(); + }); + } +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java index 4165699..31a222d 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptInferenceClient.java @@ -22,6 +22,22 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Client for invoking the SAP RPT-1 foundation model for tabular predictions. This class is part of + * the public API and can be used directly by applications that need to perform custom inference + * outside the automatic Fiori recommendation flow. + * + *

Example usage: + * + *

{@code
+ * AICoreService service = ...;
+ * String rg = service.resourceGroupForTenant(tenantId);
+ * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
+ * RptInferenceClient client =
+ *     new RptInferenceClient(service.inferenceClient(rg, deploymentId), service.getRetry());
+ * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
+ * }
+ */ public class RptInferenceClient implements RecommendationClient { private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class);