diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java new file mode 100644 index 0000000..764bb18 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICore.java @@ -0,0 +1,32 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +/** + * Constants for the AI Core plugin. + * + *

Use {@link #SERVICE_NAME} for service catalog lookups and {@code @ServiceName} annotations. + * Use the entity constants for {@code @On(entity = ...)} handler annotations. + * + *

The service is a {@link com.sap.cds.services.cds.RemoteService RemoteService} auto-created by + * the CAP Java runtime from the CDS model. Callers interact with it by emitting typed {@link + * com.sap.cds.services.EventContext EventContext} instances ({@link ResourceGroupContext}, {@link + * DeploymentIdContext}, {@link InferenceClientContext}) or via CQL on the defined entities. + */ +public final class AICore { + + private AICore() {} + + /** Service name matching the CDS service definition, used for catalog lookup. */ + public static final String SERVICE_NAME = "AICore"; + + /** Qualified name of the {@code resourceGroups} entity. */ + public static final String RESOURCE_GROUPS = "AICore.resourceGroups"; + + /** Qualified name of the {@code deployments} entity. */ + public static final String DEPLOYMENTS = "AICore.deployments"; + + /** Qualified name of the {@code configurations} entity. */ + public static final String CONFIGURATIONS = "AICore.configurations"; +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java deleted file mode 100644 index 8a2e24c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.api; - -import com.sap.cds.services.cds.RemoteService; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * CAP service contract for SAP AI Core integration. - * - *

The service exposes resource-group, configuration and deployment lifecycle as CDS entities - * (see {@link #RESOURCE_GROUPS}, {@link #DEPLOYMENTS}, {@link #CONFIGURATIONS}) and additionally - * provides programmatic helpers to: - * - *

- * - *

The implementation is tenant-aware: it reads the current tenant from the {@code - * RequestContext}. Callers do not need to pass tenant identifiers explicitly. - */ -public interface AICoreService extends RemoteService { - - /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICoreService$Default"; - - /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ - String RESOURCE_GROUPS = "AICore.resourceGroups"; - - /** Qualified name of the {@code deployments} entity exposed by this service. */ - String DEPLOYMENTS = "AICore.deployments"; - - /** Qualified name of the {@code configurations} entity exposed by this service. */ - String CONFIGURATIONS = "AICore.configurations"; - - /** - * Returns the AI Core resource group ID associated with the current tenant. - * - *

When multi-tenancy is disabled the configured default resource group is returned. When - * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and - * created on first call if it does not exist. - * - * @return the AI Core resource group ID for the current tenant - */ - String resourceGroup(); - - /** - * Returns the AI Core resource group ID associated with the given tenant. - * - *

This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly - * available from the context rather than the current request. - * - * @param tenantId the CDS tenant identifier - * @return the AI Core resource group ID for the specified tenant - */ - String resourceGroupForTenant(String tenantId); - - /** - * Returns the deployment ID for the given model spec inside the given resource group. - * - *

Looks up an existing RUNNING/PENDING deployment that matches the spec, otherwise creates a - * configuration (if missing) and a new deployment, then polls until the deployment reaches - * RUNNING. Results are cached per {@code (resourceGroupId, configurationName)} pair. - * - * @param resourceGroupId the AI Core resource group to operate in - * @param spec the deployment specification (scenario, executable, configuration name and - * existing-match predicate) - * @return the deployment ID - */ - String deploymentId(String resourceGroupId, ModelDeploymentSpec spec); - - /** - * Returns an {@link ApiClient} preconfigured with the inference destination for the given - * deployment, suitable for constructing foundation-model SDK clients. - * - * @param resourceGroupId the AI Core resource group containing the deployment - * @param deploymentId the deployment ID returned by {@link #deploymentId(String, - * ModelDeploymentSpec)} - * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint - */ - ApiClient inferenceClient(String resourceGroupId, String deploymentId); -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java index 4fa04d9..04857c0 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code deploymentId} event. * - *

Emitted by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} to resolve (or - * create) a deployment matching the given spec inside the given resource group. The ON handler - * performs cache lookup, retry, configuration creation, deployment creation and polling. + *

Emitted on the AI Core service to resolve (or create) a deployment matching the given spec + * inside the given resource group. The ON handler performs cache lookup, retry, configuration + * creation, deployment creation and polling. */ @EventName(DeploymentIdContext.EVENT) public interface DeploymentIdContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java index 1b3095e..8ed8710 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -10,8 +10,8 @@ /** * Typed {@link EventContext} for the {@code inferenceClient} event. * - *

Emitted by {@link AICoreService#inferenceClient(String, String)} to build an {@link ApiClient} - * preconfigured with the inference destination for the given deployment. + *

Emitted on the AI Core service to build an {@link ApiClient} preconfigured with the inference + * destination for the given deployment. */ @EventName(InferenceClientContext.EVENT) public interface InferenceClientContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java index a51d1b6..72b29f3 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java @@ -9,8 +9,8 @@ import java.util.function.Predicate; /** - * Describes a target AI Core deployment used by {@link AICoreService#deploymentId(String, - * ModelDeploymentSpec)} to look up or create a deployment inside a resource group. + * Describes a target AI Core deployment used by the {@code deploymentId} event to look up or create + * a deployment inside a resource group. * *

The spec carries the AI Core scenario/executable identification, the human-readable * configuration name (used as a stable key for caching and idempotent reuse), the parameter diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java index 19a2a82..9d08328 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code resourceGroup} event. * - *

Emitted by {@link AICoreService#resourceGroup()} to resolve the AI Core resource group ID for - * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not - * exist. In single-tenancy mode, the configured default resource group is returned. + *

Emitted on the AI Core service to resolve the AI Core resource group ID for the current + * tenant. In multi-tenancy mode, the resource group is created on-demand if it does not exist. In + * single-tenancy mode, the configured default resource group is returned. * *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the * current tenant is read from the {@code RequestContext}. diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 202ed8d..976cd24 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -7,7 +7,7 @@ import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; @@ -18,6 +18,7 @@ import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -27,13 +28,16 @@ import org.slf4j.LoggerFactory; /** - * {@link CdsRuntimeConfiguration} that wires the {@code AICore} service and its event handlers into - * the CAP Java runtime. + * {@link CdsRuntimeConfiguration} that wires the {@code AICore} remote service and its event + * handlers into the CAP Java runtime. * - *

Detects the presence of an SAP AI Core service binding (either a regular service binding or - * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers - * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do - * not need to instantiate this class directly. + *

In the {@link #environment} phase, a {@link RemoteServiceConfig} entry for "AICore" is + * injected into the runtime properties so the framework's {@code RemoteServiceConfiguration} + * auto-creates the service instance from the CDS model. This follows the same pattern used by + * {@code cds-feature-notifications}. + * + *

In the {@link #eventHandlers} phase, production or mock handlers are registered depending on + * whether an AI Core service binding is present. */ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { @@ -43,34 +47,23 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private AICoreClients clients; private DeploymentResolver resolver; - private static boolean hasAICoreModel(CdsRuntime runtime) { - return runtime.getCdsModel().findService("AICore").isPresent(); - } - - private static boolean hasAICoreBinding(CdsRuntime runtime) { - return runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - } - /** - * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} - * property or the presence of a {@link DeploymentService} in the service catalog. This aligns - * with the standard CAP Java convention — no custom property flag is needed. + * Injects a {@link RemoteServiceConfig} for "AICore" into the runtime properties. This runs + * before all {@code services()} methods, ensuring the framework's {@code + * RemoteServiceConfiguration} will auto-create a {@code RemoteServiceImpl} for the AICore CDS + * service definition. */ - private static boolean detectMultiTenancy(CdsRuntime runtime) { - CdsProperties props = runtime.getEnvironment().getCdsProperties(); - String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); - if (sidecarUrl != null && !sidecarUrl.isBlank()) { - return true; - } - return runtime - .getServiceCatalog() - .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) - != null; + @Override + public void environment(CdsRuntimeConfigurer configurer) { + RemoteServiceConfig remoteConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + remoteConfig.setModel(AICore.SERVICE_NAME); + configurer + .getCdsRuntime() + .getEnvironment() + .getCdsProperties() + .getRemote() + .getServices() + .putIfAbsent(AICore.SERVICE_NAME, remoteConfig); } @Override @@ -78,7 +71,7 @@ public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); if (!hasAICoreModel(runtime)) { - logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + logger.debug("AICore CDS model not found in runtime model - skipping handler setup."); return; } @@ -96,23 +89,19 @@ public void services(CdsRuntimeConfigurer configurer) { this.clients = new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - logger.info("Registered AICoreService backed by AI Core binding."); + logger.info("AI Core binding detected - production handlers will be registered."); } else { - logger.info( - "Registered AICoreService (no AI Core binding found — mock handlers will be used)."); + logger.info("No AI Core binding found - mock handlers will be registered."); } - - configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime)); } @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { if (config == null) { - return; // No AICore model — services() skipped registration + return; // No AICore model - services() skipped } if (clients != null) { - // Production path: real AI Core binding configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); @@ -125,7 +114,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); } } else { - // Mock path: no AI Core binding MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); configurer.eventHandler(new MockEntityHandler()); configurer.eventHandler(mockApiHandler); @@ -137,4 +125,29 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } } + + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService(AICore.SERVICE_NAME).isPresent(); + } + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; + } } 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 deleted file mode 100644 index b1c226c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.api.DeploymentIdContext; -import com.sap.cds.feature.aicore.api.InferenceClientContext; -import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.feature.aicore.api.ResourceGroupContext; -import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * Production implementation of {@link AICoreService}. - * - *

This class is a pure delegation layer: each API method creates a typed {@link - * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and - * returns the handler's result. All business logic (caching, locking, API calls) lives in the - * registered ON handlers which receive their dependencies via constructor injection. - * - *

Implementation note: This class extends {@code AbstractCdsDefinedService} - * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public - * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding. - * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not - * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and - * version-compatibility is verified through integration tests against the CAP Java runtime. - */ -public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - - private static final String CDS_DEFINITION_NAME = "AICore"; - - public AICoreServiceImpl(String name, CdsRuntime runtime) { - super(name, CDS_DEFINITION_NAME, runtime); - } - - @Override - public String resourceGroup() { - ResourceGroupContext ctx = ResourceGroupContext.create(); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String resourceGroupForTenant(String tenantId) { - ResourceGroupContext ctx = ResourceGroupContext.create(); - ctx.setTenantId(tenantId); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - DeploymentIdContext ctx = DeploymentIdContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setSpec(spec); - emit(ctx); - return ctx.getResult(); - } - - @Override - public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - InferenceClientContext ctx = InferenceClientContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setDeploymentId(deploymentId); - emit(ctx); - return ctx.getResult(); - } -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java index 3ecf8e7..613cdd1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -10,7 +10,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; @@ -32,13 +32,13 @@ import org.slf4j.LoggerFactory; /** - * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, + * ON handler for the AI Core service API events ({@code resourceGroup}, {@code deploymentId}, * {@code inferenceClient}). * *

Contains the business logic for deployment discovery/creation and inference client * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class AICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); 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 2c9d1e7..8fb95f5 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 @@ -5,7 +5,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -19,7 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); 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 bf4e1d8..76c6642 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 @@ -7,7 +7,7 @@ import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -32,7 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); 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 4657bc4..193a5ae 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 @@ -9,7 +9,7 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java index f5155a2..133ebd1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -3,7 +3,7 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; @@ -20,10 +20,10 @@ import org.slf4j.LoggerFactory; /** - * Mock ON handler for the {@link AICoreService} API events when no AI Core binding is available. - * Uses in-memory maps instead of real API calls. + * Mock ON handler for the AI Core service API events when no AI Core binding is available. Uses + * in-memory maps instead of real API calls. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class MockAICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java index 2ac76ab..4680b7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -4,7 +4,7 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -25,7 +25,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class MockEntityHandler implements EventHandler { private final Map> resourceGroups = new ConcurrentHashMap<>(); @@ -34,7 +34,7 @@ public class MockEntityHandler implements EventHandler { // --- Resource Groups --- - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void readResourceGroups(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -61,7 +61,7 @@ public void readResourceGroups(CdsReadEventContext context) { } } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void createResourceGroups(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -76,7 +76,7 @@ public void createResourceGroups(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void updateResourceGroups(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -92,7 +92,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICore.RESOURCE_GROUPS) public void deleteResourceGroups(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -105,7 +105,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) { // --- Deployments --- - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void readDeployments(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -121,7 +121,7 @@ public void readDeployments(CdsReadEventContext context) { } } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void createDeployments(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -136,7 +136,7 @@ public void createDeployments(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void updateDeployments(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -152,7 +152,7 @@ public void updateDeployments(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = AICore.DEPLOYMENTS) public void deleteDeployments(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -165,7 +165,7 @@ public void deleteDeployments(CdsDeleteEventContext context) { // --- Configurations --- - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = AICore.CONFIGURATIONS) public void readConfigurations(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -181,7 +181,7 @@ public void readConfigurations(CdsReadEventContext context) { } } - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = AICore.CONFIGURATIONS) public void createConfigurations(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); 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 9471662..016b833 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 @@ -9,7 +9,7 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore.SERVICE_NAME) public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index c6612a6..42cb7a5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -5,7 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -17,8 +18,8 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Since the test runtime has no service bindings, the configuration always registers an {@link - * AICoreServiceImpl} with mock handlers regardless of environment variables. + *

Since the test runtime has no service bindings, the configuration always registers mock + * handlers regardless of environment variables. */ class AICoreServiceConfigurationTest { @@ -26,15 +27,16 @@ class AICoreServiceConfigurationTest { void noBinding_noMultiTenancy_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -48,15 +50,16 @@ void noBinding_withSidecarUrl_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -67,8 +70,8 @@ void noModel_skipsServiceRegistration() { .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); assertThat(service).isNull(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 9c66431..84979cf 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -23,16 +23,19 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -53,7 +56,7 @@ class AICoreServiceImplDeploymentIdTest { private DeploymentApi deploymentApi; private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; - private AICoreServiceImpl service; + private RemoteService service; private DeploymentResolver resolver; private final ModelDeploymentSpec spec = @@ -64,16 +67,19 @@ private String cacheKey() { } /** - * Creates an {@link AICoreServiceImpl} properly registered with a CDS runtime and the {@link + * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link * AICoreApiHandler} so that {@code emit()} dispatches to the handler. */ - private AICoreServiceImpl createService(boolean multiTenancy) { - TestPropertiesProvider props = new TestPropertiesProvider(); - props.setProperty("cds.ai.core.maxRetries", 1); - props.setProperty("cds.ai.core.initialDelayMs", 1L); - - CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + private RemoteService createService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + CdsRuntimeConfigurer configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); CdsRuntime runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); @@ -82,11 +88,10 @@ private AICoreServiceImpl createService(boolean multiTenancy) { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(svc); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.complete(); - return svc; + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach @@ -105,7 +110,7 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); verify(deploymentApi).get(RG, DEPLOYMENT_ID); @@ -130,7 +135,7 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Excepti when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(otherDeployment); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); @@ -144,7 +149,7 @@ void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); + assertThatThrownBy(() -> emitDeploymentId(service, RG, spec)).rootCause().isSameAs(serverError); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); @@ -162,7 +167,7 @@ void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -185,8 +190,8 @@ void secondCallUsesCachedResult_singleQueryToApi() { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String first = service.deploymentId(RG, spec); - String second = service.deploymentId(RG, spec); + String first = emitDeploymentId(service, RG, spec); + String second = emitDeploymentId(service, RG, spec); assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); @@ -197,15 +202,15 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - AICoreServiceImpl mtService = createService(true); + RemoteService mtService = createService(true); - String result = mtService.resourceGroupForTenant(null); + String result = emitResourceGroup(mtService, null); assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - String result = service.resourceGroupForTenant("any-tenant"); + String result = emitResourceGroup(service, "any-tenant"); assertThat(result).isEqualTo("default"); } @@ -232,7 +237,7 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E when(runningPoll.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(runningPoll); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -244,6 +249,21 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E // Helpers // ────────────────────────────────────────────────────────────────────────── + private static String emitDeploymentId(RemoteService svc, String rg, ModelDeploymentSpec spec) { + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(rg); + ctx.setSpec(spec); + svc.emit(ctx); + return ctx.getResult(); + } + + private static String emitResourceGroup(RemoteService svc, String tenantId) { + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + svc.emit(ctx); + return ctx.getResult(); + } + @SuppressWarnings("unchecked") private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) throws Exception { @@ -260,27 +280,4 @@ private static Map getDeploymentCache(DeploymentResolver resolve field.setAccessible(true); return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); } - - /** Properties provider that allows overriding specific keys for test configuration. */ - private static class TestPropertiesProvider extends SimplePropertiesProvider { - private final Map properties = new HashMap<>(); - - TestPropertiesProvider() { - super(new CdsProperties()); - } - - void setProperty(String key, Object value) { - properties.put(key, value); - } - - @Override - @SuppressWarnings("unchecked") - public T getProperty(String key, Class asClazz, T defaultValue) { - Object value = properties.get(key); - if (value != null && asClazz.isInstance(value)) { - return (T) value; - } - return defaultValue; - } - } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index c555b58..056e0fc 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -5,9 +5,12 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -21,7 +24,7 @@ */ class MockAICoreServiceImplTest { - private AICoreService createMockService(boolean multiTenancy) { + private RemoteService createMockService(boolean multiTenancy) { CdsProperties props = new CdsProperties(); if (multiTenancy) { CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); @@ -33,55 +36,84 @@ private AICoreService createMockService(boolean multiTenancy) { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @Test void noMultiTenancy_resourceGroupReturnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroup()).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void noMultiTenancy_resourceGroupForTenant_returnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroupForTenant("any-tenant")).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void multiTenancy_resourceGroupForTenant_returnsPrefixed() { - AICoreService service = createMockService(true); - String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("cds-my-tenant"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("my-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant"); } @Test void multiTenancy_resourceGroupForTenant_cachesResult() { - AICoreService service = createMockService(true); - String first = service.resourceGroupForTenant("t1"); - String second = service.resourceGroupForTenant("t1"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("t1"); + service.emit(rgCtx1); + String first = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("t1"); + service.emit(rgCtx2); + String second = rgCtx2.getResult(); assertThat(first).isEqualTo(second); } @Test void deploymentId_returnsMockId() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String id = service.deploymentId("default", spec); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId("default"); + depCtx.setSpec(spec); + service.emit(depCtx); + String id = depCtx.getResult(); assertThat(id).startsWith("mock-deployment-"); } @Test void deploymentId_cachesSameResult() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String first = service.deploymentId("default", spec); - String second = service.deploymentId("default", spec); + + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId("default"); + depCtx1.setSpec(spec); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId("default"); + depCtx2.setSpec(spec); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(first).isEqualTo(second); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 7fdb3e2..f6de01a 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -20,14 +20,15 @@ import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -47,7 +48,7 @@ class ConfigurationHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ConfigurationApi configurationApi; private static ResourceGroupApi resourceGroupApi; @@ -57,8 +58,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); DeploymentApi deploymentApi = mock(DeploymentApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -67,11 +74,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index 0e47302..44743d5 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -21,16 +21,17 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Update; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -50,7 +51,7 @@ class DeploymentHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; private static ConfigurationApi configurationApi; @@ -61,8 +62,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -71,11 +78,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 4dfc443..a635b98 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -22,15 +22,16 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -51,7 +52,7 @@ class ResourceGroupHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ResourceGroupApi resourceGroupApi; @BeforeAll @@ -60,8 +61,14 @@ static void bootRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -70,11 +77,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach @@ -199,7 +206,7 @@ void onUpdate_withoutLabels_callsPatchWithoutLabels() { class MultiTenancyTests { private static CdsRuntime mtRuntime; - private static AICoreServiceImpl mtService; + private static RemoteService mtService; private static ResourceGroupApi mtResourceGroupApi; @BeforeAll @@ -208,9 +215,14 @@ static void bootMtRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); mtRuntime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -220,11 +232,12 @@ static void bootMtRuntime() { DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); - mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); - configurer.service(mtService); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + mtService = + mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index 078779c..168b5fc 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -18,14 +18,15 @@ import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.ql.Select; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -44,7 +45,7 @@ class TenantScopingTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; @@ -54,8 +55,14 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore.SERVICE_NAME); + rsConfig.setModel(AICore.SERVICE_NAME); + props.getRemote().getServices().put(AICore.SERVICE_NAME, rsConfig); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -64,11 +71,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } @BeforeEach 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..f00ce7e 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,12 +6,12 @@ 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; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; @@ -29,9 +29,11 @@ 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 RemoteService 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 @@ -40,9 +42,12 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, RecommendationClientResolver clientResolver) { + RemoteService aiCoreService, + RecommendationClientResolver clientResolver, + PersistenceService db) { this.aiCoreService = aiCoreService; this.clientResolver = clientResolver; + this.db = db; } void invalidateTenant(String tenantId) { @@ -77,10 +82,13 @@ 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; } + // 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; @@ -100,18 +108,13 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - if (builder.contextColumns().isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); + if (builder.keyNames().isEmpty()) { + logger.debug("Entity has no key elements, skipping predictions."); return; } - PersistenceService db = - 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."); + if (builder.contextColumns().isEmpty()) { + logger.trace("No suitable context columns found, skipping predictions."); return; } @@ -121,11 +124,19 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List allRows = builder.assembleRows(contextRows, predictRow, row); + // 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."); + return; + } + + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); RecommendationClient client = clientResolver.resolve(aiCoreService); List predictions = - client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); + client.predict(predictRow, contextRows, missingPredictionElementNames, builder.keyNames()); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -136,11 +147,9 @@ 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); - row.put("SAP_Recommendations", recommendations); + row.put(SAP_RECOMMENDATIONS, recommendations); } } 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..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 @@ -5,45 +5,46 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; 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 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, + List keyNames) { + String indexColumn = keyNames.size() == 1 ? keyNames.get(0) : "SAP_RECOMMENDATIONS_ID"; + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (predictionRow.get(col) == null) { + 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<>(); + // 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)); } } - return predictions; + if (!keyNames.isEmpty()) { + 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/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index afde35c..1d3df83 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 @@ -3,12 +3,17 @@ */ package com.sap.cds.feature.recommendation; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; 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.cds.RemoteService; +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; @@ -25,21 +30,31 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); - AICoreService aiCoreService = - serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService aiCoreService = + serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME); if (aiCoreService == null) { logger.info("No AICoreService found, skipping Fiori recommendation handler registration."); return; } + 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 ? 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)); } @@ -49,13 +64,26 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { .getEnvironment() .getServiceBindings() .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() + .findAny() .isPresent(); } - private static RecommendationClient resolveRptClient(AICoreService service) { - String resourceGroup = service.resourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); + private static RecommendationClient resolveRptClient(RemoteService service) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(resourceGroup); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + + return new RptInferenceClient(infCtx.getResult()); } } 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..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 @@ -14,23 +14,26 @@ 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; 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. + * 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 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 = EnumSet.of( CdsBaseType.STRING, @@ -64,8 +67,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; @@ -74,10 +75,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() { @@ -88,30 +85,27 @@ List contextColumns() { return contextColumns; } - String indexColumn() { - return indexColumn; - } - - boolean syntheticKeyNeeded() { - return syntheticKeyNeeded; + List keyNames() { + return 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)) .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); 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) @@ -121,49 +115,23 @@ 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); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); - } + 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); } - 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(); - } - - 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() @@ -171,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(); } @@ -178,10 +147,13 @@ 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()) .map(CdsElement::getName) .toList(); } 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..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 @@ -8,5 +8,21 @@ 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. + // + // @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, + List keyNames); } 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..fdd71c6 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,10 +3,12 @@ */ package com.sap.cds.feature.recommendation.api; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.cds.RemoteService; +// 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 { - RecommendationClient resolve(AICoreService aiCoreService); + RecommendationClient resolve(RemoteService aiCoreService); } 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..ed7d238 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; @@ -33,41 +31,59 @@ *

Example usage: * *

{@code
- * 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(rows, List.of("targetColumn"), "ID");
+ * RemoteService service = runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME);
+ * ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ * service.emit(rgCtx);
+ * String rg = rgCtx.getResult();
+ * DeploymentIdContext depCtx = DeploymentIdContext.create();
+ * depCtx.setResourceGroupId(rg);
+ * depCtx.setSpec(RptModelSpec.rpt1());
+ * service.emit(depCtx);
+ * InferenceClientContext infCtx = InferenceClientContext.create();
+ * infCtx.setResourceGroupId(rg);
+ * infCtx.setDeploymentId(depCtx.getResult());
+ * service.emit(infCtx);
+ * RptInferenceClient client = new RptInferenceClient(infCtx.getResult());
+ * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"), List.of("ID"));
  * }
*/ public class RptInferenceClient implements RecommendationClient { private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); - private static final Set MANAGED_FIELDS = - Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); + // 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 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())); } @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - PredictRequestPayload request = buildRequest(rows, predictionColumns, indexColumn); + CdsData predictionRow, + List contextRows, + List predictionColumns, + List keyNames) { + String indexColumn = resolveIndexColumn(keyNames, predictionRow); + CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); + List allRows = new java.util.ArrayList<>(contextRows); + allRows.add(preparedPredictRow); + addSyntheticKeyIfNeeded(allRows, keyNames, indexColumn); + + 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, () -> { - var response = api.predict(request); + var response = rpt.predict(request); logger.debug("Prediction response id: {}", response.getId()); List> raw = JacksonConfiguration.getDefaultObjectMapper() @@ -77,6 +93,51 @@ 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) { + 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 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); + for (String col : predictionColumns) { + preparedPredictRowMap.putIfAbsent(col, PREDICT); + } + return CdsData.create(preparedPredictRowMap); + } + private static PredictRequestPayload buildRequest( List rows, List predictionColumns, String indexColumn) { var targetColumns = @@ -85,31 +146,11 @@ private static PredictRequestPayload buildRequest( col -> TargetColumnConfig.create() .name(col) - .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .predictionPlaceholder(PredictionPlaceholder.create(PREDICT)) .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)).toList(); return PredictRequestPayload.create() .predictionConfig(PredictionConfig.create().targetColumns(targetColumns)) @@ -117,6 +158,18 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } + // 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) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + return sdkRow; + } + private static Retry buildInferenceRetry() { RetryConfig config = RetryConfig.custom() 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..34b9972 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,11 +13,11 @@ 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; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.utils.CdsServiceUtils; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.request.RequestContext; @@ -45,7 +45,7 @@ class FioriRecommendationHandlerTest { private static PersistenceService db; @Mock(answer = Answers.CALLS_REAL_METHODS) - private AICoreService aiCoreService; + private RemoteService aiCoreService; private FioriRecommendationHandler cut; private RecommendationClient predictionClient; @@ -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 ────────────────────────────────────────────────────────────────── @@ -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, keyNames) -> 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)); @@ -203,6 +203,55 @@ 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 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( @@ -393,55 +442,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, keyNames) -> { + 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(keyNames.get(0), predictionRow.get(keyNames.get(0))); + 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, keyNames) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + 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))); } } - return predictions; + prediction.put(keyNames.get(0), predictionRow.get(keyNames.get(0))); + return List.of(CdsData.create(prediction)); }; } } 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..4ab68d8 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 @@ -5,9 +5,11 @@ import static org.mockito.Mockito.*; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; 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; @@ -23,7 +25,8 @@ class RecommendationConfigurationTest { @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; - @Mock private AICoreService aiCoreService; + @Mock private RemoteService aiCoreService; + @Mock private PersistenceService persistenceService; @Test void aiCoreServiceFound_registersHandler() { @@ -31,8 +34,10 @@ void aiCoreServiceFound_registersHandler() { when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); when(environment.getServiceBindings()).thenReturn(Stream.empty()); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + when(serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME)) .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); new RecommendationConfiguration().eventHandlers(configurer); @@ -43,8 +48,7 @@ void aiCoreServiceFound_registersHandler() { void aiCoreServiceNull_doesNotRegisterHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(null); + when(serviceCatalog.getService(RemoteService.class, AICore.SERVICE_NAME)).thenReturn(null); new RecommendationConfiguration().eventHandlers(configurer); 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..5784e21 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,83 @@ +/* + * © 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 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")); + 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'); + } +} 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..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; @@ -46,6 +48,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 +65,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; } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java index 6e88c40..1ad8aa5 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -7,8 +7,10 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -57,19 +59,22 @@ void unsubscribe_isIdempotent() throws Exception { @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); // After subscribe, the service should resolve a resource group for this tenant - String rg = service.resourceGroupForTenant(TENANT); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(TENANT); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).isNotNull().isNotBlank(); subscriptionEndpointClient.unsubscribeTenant(TENANT); } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java index eb1b9d2..fa53769 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -9,8 +9,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -50,12 +52,15 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); // After subscription, the service should be able to resolve a resource group for the tenant - String resourceGroup = service.resourceGroupForTenant("tenant-3"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-3"); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); assertThat(resourceGroup).isNotNull().isNotBlank(); } @@ -82,7 +87,7 @@ void tearDown() { } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java index 7aa06b8..5e44cfc 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -6,9 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -44,13 +46,20 @@ void multiTenancyEnabled() { @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.resourceGroupForTenant("tenant-1"); - String rg2 = service.resourceGroupForTenant("tenant-2"); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-1"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-2"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isNotNull(); assertThat(rg2).isNotNull(); @@ -60,16 +69,19 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { AICoreConfig config = getConfig(); - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.resourceGroupForTenant("tenant-1"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-1"); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } private AICoreConfig getConfig() { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java index ce0c89b..795d6f8 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -5,9 +5,11 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -23,15 +25,18 @@ void prepareDeployment() { @Test void service_isRegistered() { assertThat(getAICoreService()).isNotNull(); - assertThat(getAICoreService()).isInstanceOf(AICoreService.class); + assertThat(getAICoreService()).isInstanceOf(RemoteService.class); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (!config.multiTenancyEnabled()) { - String result = service.resourceGroupForTenant("any-tenant"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @@ -39,29 +44,43 @@ void resourceGroupForTenant_singleTenancy_returnsDefault() { @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (config.multiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); // Second call should return cached value - String cached = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId(tenantId); + service.emit(rgCtx2); + String cached = rgCtx2.getResult(); assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); // Second call should use cache - String cached = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String cached = depCtx2.getResult(); assertThat(cached).isEqualTo(deploymentId); } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java index 0e7bf34..39f430e 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -9,12 +9,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; @@ -34,39 +35,58 @@ void ensureResourceGroupReady() { @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String result = service.resourceGroupForTenant("any-tenant-id"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant-id"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } @Test void resourceGroupForTenant_multiTenancy_createsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); } @Test void deploymentId_returnsValidDeployment() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); } @Test void deploymentId_cachedOnSecondCall() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId(resourceGroup); + depCtx1.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(second).isEqualTo(first); } @@ -75,7 +95,7 @@ void deploymentId_cachedOnSecondCall() { + "re-enable once test creates its own isolated deployment") @Test void stop_deployment_changesTargetStatus() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index 06a09af..38dd0ca 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -5,12 +5,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; @@ -39,8 +40,8 @@ public abstract class BaseIntegrationTest { @Autowired protected CdsRuntime runtime; - protected AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + protected RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } protected AICoreConfig getAICoreConfig() { @@ -50,8 +51,8 @@ protected AICoreConfig getAICoreConfig() { return AICoreConfig.from(runtime.getEnvironment(), mt); } - protected CqnService getAICoreCqnService() { - return (CqnService) getAICoreService(); + protected RemoteService getAICoreCqnService() { + return getAICoreService(); } protected String ensureRptDeploymentReady() { @@ -60,11 +61,16 @@ protected String ensureRptDeploymentReady() { resourceGroup, rg -> { ensureResourceGroupProvisioned(getAICoreCqnService(), rg); - return getAICoreService().deploymentId(rg, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + return depCtx.getResult(); }); } - protected void ensureResourceGroupProvisioned(CqnService service, String resourceGroup) { + protected void ensureResourceGroupProvisioned(RemoteService service, String resourceGroup) { if (!resourceGroupExists(service, resourceGroup)) { logger.info("Creating resource group {} with itest owner label", resourceGroup); service.run( @@ -79,7 +85,7 @@ protected void ensureResourceGroupProvisioned(CqnService service, String resourc waitForResourceGroupProvisioned(service, resourceGroup); } - private boolean resourceGroupExists(CqnService service, String resourceGroup) { + private boolean resourceGroupExists(RemoteService service, String resourceGroup) { Result all = service.run(Select.from("AICore.resourceGroups")); for (Row row : all) { if (resourceGroup.equals(row.get("resourceGroupId"))) { @@ -89,7 +95,7 @@ private boolean resourceGroupExists(CqnService service, String resourceGroup) { return false; } - private void waitForResourceGroupProvisioned(CqnService service, String resourceGroup) { + private void waitForResourceGroupProvisioned(RemoteService service, String resourceGroup) { for (int i = 0; i < 30; i++) { Result all = service.run(Select.from("AICore.resourceGroups")); for (Row row : all) { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java index 2b1e5b5..9b7d1af 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -9,7 +9,7 @@ import com.sap.cds.Row; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -18,7 +18,7 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -30,7 +30,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -46,7 +46,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); @@ -91,7 +91,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java index d73fd50..c1111dc 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -10,7 +10,7 @@ import com.sap.cds.Row; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -19,7 +19,7 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( @@ -31,7 +31,7 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result all = service.run( @@ -62,7 +62,7 @@ void readSingle_returnsDeploymentDetails() { + "re-enable once test creates its own isolated deployment") @Test void update_targetStatus_stopsRunningDeployment() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java index 2716d06..9d88af9 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -7,8 +7,9 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.Test; class MultiTenancyTest extends BaseIntegrationTest { @@ -16,13 +17,20 @@ class MultiTenancyTest extends BaseIntegrationTest { @Test void differentTenants_getDifferentResourceGroups() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-mt-a-" + System.currentTimeMillis(); String tenantB = "itest-mt-b-" + System.currentTimeMillis(); - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); + ResourceGroupContext rgCtxA = ResourceGroupContext.create(); + rgCtxA.setTenantId(tenantA); + service.emit(rgCtxA); + String rgA = rgCtxA.getResult(); + + ResourceGroupContext rgCtxB = ResourceGroupContext.create(); + rgCtxB.setTenantId(tenantB); + service.emit(rgCtxB); + String rgB = rgCtxB.getResult(); assertThat(rgA).isNotEqualTo(rgB); assertThat(rgA).contains(tenantA); @@ -32,21 +40,32 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-prefix-" + System.currentTimeMillis(); - String rg = service.resourceGroupForTenant(tenantA); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantA); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String rg1 = service.resourceGroupForTenant("tenant-x"); - String rg2 = service.resourceGroupForTenant("tenant-y"); + + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-x"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-y"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isEqualTo(config.defaultResourceGroup()); assertThat(rg2).isEqualTo(config.defaultResourceGroup()); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java index e6ca394..6c00532 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -11,7 +11,7 @@ import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; @@ -30,7 +30,7 @@ class ResourceGroupTest extends BaseIntegrationTest { void cleanup() { if (createdResourceGroupId != null) { try { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); waitForResourceGroupProvisioned(service, createdResourceGroupId); service.run( Delete.from("AICore.resourceGroups") @@ -44,7 +44,7 @@ void cleanup() { @Test void create_andRead_resourceGroup() { createdResourceGroupId = TEST_RG_PREFIX + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -65,7 +65,7 @@ void create_andRead_resourceGroup() { void create_withTenantLabel_andFilterByTenant() { String tenantId = "itest-tenant-" + System.currentTimeMillis(); createdResourceGroupId = TEST_RG_PREFIX + tenantId; - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -82,7 +82,7 @@ void create_withTenantLabel_andFilterByTenant() { @Test void readAll_returnsResourceGroups() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); Result result = service.run(Select.from("AICore.resourceGroups")); assertThat(result.list()).isNotNull(); } @@ -90,7 +90,7 @@ void readAll_returnsResourceGroups() { @Test void create_withLabels() { createdResourceGroupId = TEST_RG_PREFIX + "labels-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run( Insert.into("AICore.resourceGroups") @@ -119,7 +119,7 @@ void create_withLabels() { @Test void delete_resourceGroup() throws InterruptedException { String rgId = TEST_RG_PREFIX + "del-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreCqnService(); service.run(Insert.into("AICore.resourceGroups").entry(Map.of("resourceGroupId", rgId))); @@ -135,7 +135,7 @@ void delete_resourceGroup() throws InterruptedException { createdResourceGroupId = null; // already deleted } - private void waitForResourceGroupProvisioned(CqnService service, String rgId) + private void waitForResourceGroupProvisioned(RemoteService service, String rgId) throws InterruptedException { for (int i = 0; i < 30; i++) { Result result = diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json index 94e9eda..f419d38 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -2,13 +2,13 @@ "requires": { "db": "hana", "AICore": { - "model": "com.sap.cds/ai" + "model": "@cap-js/ai/srv/AICoreService" }, "[production]": { "auth": "xsuaa" } }, "cdsc": { - "moduleLookupDirectories": ["node_modules/", "target/cds/"] + "moduleLookupDirectories": ["node_modules/"] } } diff --git a/samples/bookshop/srv/ai-core-service.cds b/samples/bookshop/srv/ai-core-service.cds index 7124cc4..1fc24c8 100644 --- a/samples/bookshop/srv/ai-core-service.cds +++ b/samples/bookshop/srv/ai-core-service.cds @@ -1,4 +1,4 @@ -using { AICore } from 'com.sap.cds/ai'; +using { AICore } from '@cap-js/ai/srv/AICoreService'; service AICoreShowcaseService @(requires: 'any') { 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..d2cfaf6 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 @@ -2,7 +2,10 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.AICore; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -11,6 +14,7 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; @@ -28,8 +32,8 @@ public class AICoreShowcaseHandler implements EventHandler { @Autowired private CdsRuntime runtime; - private AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore.SERVICE_NAME); } // This handler is NOT required - the plugin automatically delegates reads on projections @@ -42,14 +46,20 @@ public void onReadConfigurations(CdsReadEventContext context) { @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @@ -57,7 +67,12 @@ public void onGetMyResourceGroup(EventContext context) { @On(event = "provisionRpt1") public void onProvisionRpt1(EventContext context) { String resourceGroupId = (String) context.get("resourceGroupId"); - String deploymentId = getAICoreService().deploymentId(resourceGroupId, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroupId); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); context.put("result", deploymentId); context.setCompleted(); } @@ -112,45 +127,52 @@ 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)); - } - - 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"); + 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"))); + + RemoteService service = getAICoreService(); + + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(rg); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + RptInferenceClient client = new RptInferenceClient(infCtx.getResult()); 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);