diff --git a/cds-feature-ai-core/docs/architecture.md b/cds-feature-ai-core/docs/architecture.md new file mode 100644 index 0000000..4d933bb --- /dev/null +++ b/cds-feature-ai-core/docs/architecture.md @@ -0,0 +1,282 @@ +# Architecture: `cds-feature-ai-core` + +## Table of Contents + +- [Purpose](#purpose) +- [Dependencies](#dependencies) +- [Feature](#feature) + - [CDS Model](#cds-model) + - [Public API](#public-api) + - [Key Infrastructure Classes](#key-infrastructure-classes) + - [Multi-Tenancy](#multi-tenancy) + - [Key Flows](#key-flows) + - [Tenant Subscribe](#tenant-subscribe) + - [Tenant Unsubscribe](#tenant-unsubscribe) + - [Inference Client Resolution](#inference-client-resolution) +- [Tests](#tests) +- [Quality Tools](#quality-tools) +- [Architecture Decisions](#architecture-decisions) + +--- + +## Purpose + +Bridges CAP Java to SAP AI Core's management and inference REST APIs, providing resource group management, deployment lifecycle, and inference client resolution as a CAP service. At the time of writing, `com.sap.ai.sdk:ai-core` offered no CAP integration — only raw REST API clients — so this plugin fills that gap. + +→ [README](../README.md) + +--- + +## Dependencies + +| Dependency | Why | +|---|---| +| `com.sap.ai.sdk:ai-core` (SAP AI SDK) | Provides the generated `DeploymentApi`, `ConfigurationApi`, `ResourceGroupApi`, and `ApiClient` types used to call the AI Core REST API. The plugin wraps these behind CDS events so callers never deal with the AI SDK directly. | +| `com.github.ben-manes.caffeine:caffeine` | Thread-safe in-process caching for `tenantId → resourceGroupId` and `resourceGroupId::configName → deploymentId` mappings (1 h TTL, 10k max per cache). | +| `io.github.resilience4j:resilience4j-retry` | Exponential backoff (initial 300 ms, doubling, max 30 s, up to 10 attempts) on 403/404/412 responses from AI Core - needed because resource group creation is asyncronous. | +| `com.sap.cds:cds-services-api/-impl/-utils` | CAP Java integration — used to integrate the plugin into the CAP runtime. | +| CAP Java `DeploymentService` | MTX lifecycle hook: `AICoreSetupHandler` subscribes to `SubscribeEvent` (`@After LATE`) and `UnsubscribeEvent` (`@Before EARLY`) to create/delete per-tenant resource groups automatically. | + +--- + +## Feature + +### CDS Model + +Defined in `src/main/resources/cds/`: `AICore.cds` + +```cds +// @protocol: 'none' — programmatic access only, never exposed as OData/REST +service AICore { + @cds.persistence.skip entity resourceGroups { ... } // AI Core resource group + @cds.persistence.skip entity deployments { ... } // AI Core deployment + action stop() + @cds.persistence.skip entity configurations { ... } // AI Core configuration +} +``` + +Entities are `@cds.persistence.skip` — they have no database tables and are backed entirely by the AI Core REST API at runtime. + +The three custom events are **not declared in the CDS model** — they are Java-only `@EventName`-annotated `EventContext` interfaces in the `api` package: + +| Java interface | Event name | In → Out | +|---|---|---| +| `ResourceGroupContext` | `resourceGroup` | `tenantId?` → `resourceGroupId` | +| `DeploymentIdContext` | `deploymentId` | `resourceGroupId + ModelDeploymentSpec` → `deploymentId` | +| `InferenceClientContext` | `inferenceClient` | `resourceGroupId + deploymentId` → `ApiClient` | + +--- + +### Public API + +Three custom CDS events are the only stable contract. All other classes are internal. + +```java +// Resolve resource group for the current tenant +String rgId = aiCoreService.resourceGroup(); + +// Resolve (or lazily create) a deployment matching the given model spec +String deploymentId = aiCoreService.deploymentId(rgId, RptModelSpec.rpt1()); + +// Obtain a pre-configured ApiClient for inference +ApiClient client = aiCoreService.inferenceClient(rgId, deploymentId); +``` + +See also [Programmatic Usage in README](../README.md#programmatic-usage). + +--- + +### Key Infrastructure Classes + +| Class | Role | +|---|---| +| `AICoreServiceConfiguration` | extends `CdsRuntimeConfiguration` — wires all handlers, clients, and caches at startup; detects AI Core binding | +| `AICoreConfig` | Immutable config record populated from `cds.ai.core.*` YAML properties | +| `AICoreClients` | Holds `DeploymentApi`, `ConfigurationApi`, `ResourceGroupApi`, and the raw `AiCoreService` from the AI SDK | +| `DeploymentResolver` | Thread-safe resolver with two Caffeine caches (`tenantId → rgId`, `rgId::configName → deploymentId`) and `ConcurrentHashMap` per-key locks (prevents duplicate deployments under concurrency); Resilience4j backoff on 403/404/412 | +| `AICoreApiHandler` | `@On` handler for the three custom events: `resourceGroup`, `deploymentId`, `inferenceClient` | +| `AICoreSetupHandler` | `@After(LATE) SubscribeEvent` / `@Before(EARLY) UnsubscribeEvent` — creates/deletes resource groups during MTX tenant lifecycle | +| `AbstractCrudHandler` | Base for all entity CRUD handlers; provides `resolveResourceGroup()` and `ensureResourceGroupAccessible()` (tenant isolation guard) | + +--- + +### Multi-Tenancy + +→ [Multi-Tenancy in README](../README.md#multi-tenancy) + +MT mode is detected automatically at startup: if `cds.multiTenancy.sidecar.url` is set or a `DeploymentService` bean is present in the CAP service catalog, MT mode is active. Resource groups are named `{resourceGroupPrefix}{tenantId}` (default prefix: `cds-`) and labelled `ext.ai.sap.com/CDS_TENANT_ID = ` so they can be looked up by tenant. + +--- + +### Key Flows + +#### Tenant Subscribe + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% +flowchart TD + classDef process fill:#fff,stroke:#000,color:#000 + classDef skip fill:#f4f4f4,stroke:#000,color:#000 + + START@{ shape: sm-circ, label: "start" } --> A(["CAP MTX DeploymentService fires SubscribeEvent @After(LATE)"]):::process + A --> B(["AICoreSetupHandler: resolveResourceGroup(tenantId)"]):::process + B --> C(["GET /v2/admin/resourceGroups?labelFilter=CDS_TENANT_ID=tenantId"]):::process + C --> D{resource group found?} + D -->|yes| E(["cache resourceGroupId (expireAfterAccess 1h)"]):::process + D -->|no| F(["POST /v2/admin/resourceGroups"]):::process + F --> E + E --> END1@{ shape: framed-circle, label: "stop" } + + style START fill:#000,stroke:#000,color:#000 + style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 +``` + +#### Inference Client Resolution + +Every prediction request or inference call requires a fully-configured `ApiClient` that is scoped to a specific AI Core deployment. The challenge is that creating this client requires three sequential steps +— tenant → resource group +- resource group + model spec (e.g. RPT-1) → deployment +- deployment → ApiClient +Each of these involves a remote API call to AI Core (resource groups and deployments are created asynchronously and may not be immediately available). The plugin solves this with per-step Caffeine caches (1 h TTL) to avoid redundant AI Core API calls, `ConcurrentHashMap` per-key locks to prevent duplicate deployment creation under concurrent requests, and Resilience4j exponential backoff to handle the window between a resource group or deployment being created and it becoming usable. + +The three steps map directly to the three `EventContext` interfaces and are executed in sequence by `AICoreServiceImpl`: + +##### Event 1: resourceGroup + +Emitted by any caller (e.g. `cds-feature-recommendations`) via `AICoreService.resourceGroup()` to resolve the AI Core resource group ID for the current tenant. In single-tenant mode, the configured default resource group is returned directly. In multi-tenant mode, `DeploymentResolver` looks up the resource group by the `ext.ai.sap.com/CDS_TENANT_ID` label with a `GET` to `/v2/admin/resourceGroups`. The result is cached for 1 h (expire-after-access) so the AI Core management API is not called on every request. If no resource group is found, it creates one via `POST` to `/v2/admin/resourceGroups` (tolerating 409 conflicts), caches the result, and returns the resource group ID. +Unlike the deployment cache, there is **no validation on cache hits** — if a resource group is deleted externally during that window, the stale ID stays cached and subsequent prediction requests will fail until the entry expires or the app restarts. + + +##### Event 2: deploymentId — invoked with `resourceGroupId` + +Emitted by callers via `AICoreService.deploymentId(rgId, spec)` to resolve (or lazily create) a running deployment matching the given `ModelDeploymentSpec` inside the resource group. `DeploymentResolver` first checks its deployment cache; on a cache miss or invalid cached entry it queries AI Core for an existing RUNNING/PENDING deployment, and — if none exists — creates the configuration and deployment, then polls until RUNNING with exponential backoff: Resilience4j exponential backoff (300 ms initial, doubling, capped at 30 s, max 10 attempts) on: 403/412 during deployment creation (`POST /v2/lm/deployments`); 403/404/412 during deployment polling (`GET /v2/lm/deployments`). A `ConcurrentHashMap` per-key lock prevents duplicate deployments from being created under concurrent first-use requests. The resolved deployment ID is cached for 1 h. + + +##### Event 3: inferenceClient — invoked with `resourceGroupId` and `deploymentId` + +Emitted by callers via `AICoreService.inferenceClient(rgId, deploymentId)` to obtain a pre-configured `ApiClient` ready to make prediction requests against a specific deployment. The handler delegates to the SAP AI SDK's `AiCoreService` to build an inference destination scoped to the resource group and deployment, then wraps it in an `ApiClient`. No caching — the client is lightweight to construct and callers are expected to obtain it once per request. + +#### Full flow + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% +flowchart TD + classDef process fill:#fff,stroke:#000,color:#000 + classDef skip fill:#f4f4f4,stroke:#000,color:#000 + + START@{ shape: sm-circ, label: "start" } --> A(["Consumer: getAICoreService() → RemoteService"]):::process + + A --> A1(["emit ResourceGroupContext (tenantId)"]):::process + A1 --> A2{multiTenancy enabled AND tenantId != null?} + A2 -->|no| A3(["return config.defaultResourceGroup()"]):::skip + A2 -->|yes| A4{tenantResourceGroupCache hit?} + A4 -->|yes| A5(["return cached resourceGroupId"]):::skip + A4 -->|no| A6(["GET /v2/admin/resourceGroups?labelSelector=...tenant={tenantId}"]):::process + A6 --> A7{found?} + A7 -->|yes| A8(["cache result (expireAfterAccess 1h)"]):::process + A7 -->|no| A9(["POST /v2/admin/resourceGroups (409 = already exists → ok)"]):::process + A9 --> A8 + A8 --> A5 + + A3 --> B1 + A5 --> B1 + + B1(["emit DeploymentIdContext (resourceGroupId, ModelDeploymentSpec)"]):::process + B1 --> B2(["acquire per-key lock (ConcurrentHashMap)"]):::process + B2 --> B3{deploymentCache hit?} + B3 -->|yes| B4(["GET /v2/lm/deployments/{id} — validate cached entry"]):::process + B4 --> B5{status RUNNING or PENDING?} + B5 -->|yes| B6(["return cached deploymentId"]):::skip + B5 -->|no / 404| B7(["invalidate cache entry"]):::process + B7 --> B8 + B3 -->|no| B8(["GET /v2/lm/deployments?scenarioId=... — query existing deployments"]):::process + B8 --> B10{match by configName + RUNNING/PENDING?} + B10 -->|yes| B11(["cache deploymentId (expireAfterAccess 1h)"]):::process + B10 -->|no| B12(["GET /v2/lm/configurations?scenarioId=... — find or create config"]):::process + B12 --> B13{config with matching name exists?} + B13 -->|yes| B14(["reuse existing configId"]):::process + B13 -->|no| B15(["POST /v2/lm/configurations"]):::process + B15 --> B14 + B14 --> B16(["POST /v2/lm/deployments (retry on 403/412)"]):::process + B16 --> B17(["poll GET /v2/lm/deployments/{id} until RUNNING (exponential backoff)"]):::process + B17 --> B11 + B11 --> B6 + + B6 --> C1(["emit InferenceClientContext (resourceGroupId, deploymentId)"]):::process + C1 --> C2(["getInferenceDestination(rgId).usingDeploymentId(depId)"]):::process + C2 --> C3(["return ApiClient.create(destination)"]):::process + C3 --> END1@{ shape: framed-circle, label: "stop" } + + style START fill:#000,stroke:#000,color:#000 + style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 +``` + +#### Tenant Unsubscribe + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% +flowchart TD + classDef process fill:#fff,stroke:#000,color:#000 + + START@{ shape: sm-circ, label: "start" } --> A(["CAP MTX DeploymentService fires UnsubscribeEvent @Before(EARLY)"]):::process + A --> B(["AICoreSetupHandler: DELETE /v2/admin/resourceGroups/{id}"]):::process + B --> C(["invalidateTenant(tenantId) — evicts tenantResourceGroupCache and deploymentCache entries"]):::process + C --> END1@{ shape: framed-circle, label: "stop" } + + style START fill:#000,stroke:#000,color:#000 + style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 +``` +--- + +## Tests + +Unit tests for `AICoreServiceConfiguration`, `AICoreServiceImpl`, `AICoreSetupHandler`, and the CRUD handlers live in `src/test/` within this module (`mvn test`). + +End-to-end integration tests against a real AI Core instance live in [`integration-tests/spring/`](../../integration-tests/README.md) (`AICoreServiceTest`, `DeploymentTest`, `ResourceGroupTest`, `MultiTenancyTest`, and others). MTX lifecycle tests (subscribe/unsubscribe/tenant isolation) live in [`integration-tests/mtx-local/`](../../integration-tests/README.md). + +--- + +## Quality Tools + +→ [CI Checks and static analysis](../../CONTRIBUTING.md#ci-checks) + +--- + +## Architecture Decisions + +### Wrapping the AI SDK in a CAP service for multi-tenant isolation + +**Context:** The SAP AI SDK (`com.sap.ai.sdk:ai-core`) provides API clients with no CAP integration. Plugins like `cds-feature-recommendations` need to resolve a resource group, a deployment, and an inference client on every request — but should not need to know about AI Core internals, tenant routing, or caching. + +**Key boundary condition:** The AI SDK's [`DestinationResolver`](https://github.com/SAP/ai-sdk-java/blob/main/core/src/main/java/com/sap/ai/sdk/core/DestinationResolver.java) always connects using `OnBehalfOf.TECHNICAL_USER_PROVIDER` — the provider tenant's service binding. There is no per-subscriber credential mechanism in the SDK. Tenant isolation is the caller's responsibility and is achieved entirely by setting the `AI-Resource-Group` HTTP header on each request to a resource group that belongs to the subscriber tenant. This is a hard constraint imposed by the SDK: any CAP integration on top of it must manage per-tenant resource group resolution itself — there is no way to "just pass a tenant ID" to the SDK and have it route correctly. +Beyond billing, proper per-tenant resource groups are also important for call history separation in AI Core: without them, all tenants' inference calls would appear under the same resource group in the AI Core audit log. + +**Decision:** Expose the three resolution steps (`resourceGroup`, `deploymentId`, `inferenceClient`) as custom CDS events on the `AICore` service. The `resourceGroup` event resolves the correct per-tenant resource group ID from the current CAP request context (`UserInfo.getTenant()`), which is then threaded through to `deploymentId` and `inferenceClient`. Every AI Core call made by `AICoreApiHandler` carries this resource group ID as the `AI-Resource-Group` header, satisfying the SDK's constraint. +Callers invoke the `AICoreService` Java API (`resourceGroup()`, `deploymentId()`, `inferenceClient()`), which emits the corresponding CDS events; `AICoreApiHandler` handles them. This keeps the AI SDK entirely internal and gives application code a standard `@On`/`@After` handler hook to override or observe each step if needed. + +--- + +### Caching resource group ids and deployment ids + +**Context:** Resolving an inference-ready `ApiClient` requires three sequential remote calls to AI Core (resource group lookup, deployment lookup/creation, client construction). AI Core deployments may not exist yet on first use and take minutes to reach RUNNING state. Calling the management API on every OData read would be prohibitively slow. + +**Solutions considered:** +- **`TenantAwareCache` (CAP built-in)** — `com.sap.cds.services.utils.TenantAwareCache` provides tenant-scoped invalidation natively and is already on the classpath via `cds-services-utils`. Not used yet but a candidate to replace the current manual Caffeine caches; see [#129](https://github.com/cap-java/cds-ai/issues/129). + - **In-process Caffeine cache per step** — zero additional infrastructure, thread-safe, configurable TTL. Accepted: the worst case (cache miss on restart or TTL expiry) is a single slow request; subsequent requests are fast. + +**Decision:** Cache `tenantId → resourceGroupId` and `resourceGroupId::configName → deploymentId` in two separate Caffeine caches with 1 h expire-after-access TTL. The deployment cache entry is validated on each cache hit (a `GET` to verify RUNNING/PENDING status) to detect externally stopped deployments. The resource group cache has no hit-validation — a stale entry causes failures until the TTL expires; this was accepted as an acceptable trade-off given that resource groups are rarely deleted externally. + +--- + +### Preventing duplicate AI deployments + +**Context:** Multiple requests may arrive concurrently before any deployment exists (e.g. on cold start of a new tenant). Without coordination, each request would independently discover the absence of a deployment and try to create one, resulting in duplicate deployments. + +**Decision:** Use a `ConcurrentHashMap` as a lock registry, synchronized on the value for the specific key being resolved. Only the first thread for a given key enters `findOrCreateDeployment`; subsequent threads wait and then find the deployment already in the cache. + +--- + +### Resilience4j exponential backoff for asynchronous AI Core operations + +**Context:** AI Core resource group and deployment creation is asynchronous. After a `POST /v2/admin/resourceGroups` or `POST /v2/lm/deployments`, subsequent calls may return 403 or 412 (precondition failed) until the resource is fully provisioned. Polling is necessary to wait for a deployment to reach RUNNING status. + +**Decision:** Use Resilience4j retry with exponential backoff (300 ms initial delay, doubling each attempt, capped at 30 s, max 10 attempts) on 403/404/412 responses from: `POST /v2/lm/deployments` (creation) and `GET /v2/lm/deployments/{id}` (polling until RUNNING). The same retry strategy covers both the "resource not yet available" and "deployment not yet running" cases. diff --git a/cds-feature-recommendations/docs/architecture.md b/cds-feature-recommendations/docs/architecture.md new file mode 100644 index 0000000..7827ec3 --- /dev/null +++ b/cds-feature-recommendations/docs/architecture.md @@ -0,0 +1,196 @@ +# Architecture: `cds-feature-recommendations` + +## Table of Contents + +- [Purpose](#purpose) +- [Dependencies](#dependencies) +- [Feature](#feature) + - [CDS Model](#cds-model) + - [Configuration](#configuration) + - [Public API / Handlers](#public-api--handlers) + - [Key Infrastructure Classes](#key-infrastructure-classes) + - [Multi-Tenancy](#multi-tenancy) + - [Key Flows](#key-flows) + - [Recommendation Pipeline (OData GET on draft entity)](#recommendation-pipeline-odata-get-on-draft-entity) + - [Context row selection](#context-row-selection) + - [MTX Model Change — Cache Invalidation](#mtx-model-change--cache-invalidation) +- [Tests](#tests) +- [Quality Tools](#quality-tools) +- [Architecture Decisions](#architecture-decisions) + +--- + +## Purpose + +Automatically injects AI-powered field recommendations from the [SAP-RPT-1](https://help.sap.com/docs/sap-ai-core/generative-ai/sap-rpt-1) tabular prediction foundation model into Fiori Elements OData responses for draft-enabled entities. Zero application code required. + +→ [README](../README.md) + +--- + +## Dependencies + +| Dependency | Why | +|---|---| +| [`cds-feature-ai-core`](../../cds-feature-ai-core/README.md) | Provides the `AICore` CDS service and `AICoreService` API used to resolve the resource group, deployment ID, and inference `ApiClient` for the RPT-1 model. Recommendations cannot function without an active AI Core connection. | +| `@cap-js/ai` (Node.js CDS plugin) | At CDS build time, the plugin adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Without this (or a manual CDS extension), predictions are computed but not serialized in OData responses. | +| `com.sap.ai.sdk.foundationmodels:sap-rpt` (SAP AI SDK) | Provides the RPT-1 model client used to call the `/predict` endpoint. | +| `com.github.ben-manes.caffeine:caffeine` | Thread-safe in-process caching for the per-tenant entity skip cache (10k max, no TTL). This might be replaced in #129.| +| `com.sap.cds:cds-services-api/-impl/-utils` | CAP Java integration — used to integrate the plugin into the CAP runtime. | + +--- + +## Feature + +### CDS Model + +No dedicated CDS model file — the plugin relies on the `AICore` service model provided by `cds-feature-ai-core`, and on the `SAP_Recommendations` navigation property injected by the `@cap-js/ai` Node.js plugin (or added manually by the application). + +The Node plugin will automatically detect fields annotated with a value list, i.e., fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`, also see [`README`](../README.md#enabling-recommendations). + +### Configuration + +Wired by `RecommendationConfiguration` (extends `CdsRuntimeConfiguration`) at startup. It detects whether an AI Core binding is present and selects production vs. mock mode accordingly — no manual activation is required. + +### Public API / Handlers + +No Java API — the plugin is entirely annotation-driven. Extend or annotate your CDS model to control which fields receive recommendations, see [`README`](../README.md#enabling-recommendations). +Currently, it is not possible to hook into the recommendation result from application code to observe the injected output, nor to override the inference call itself ([#110](https://github.com/cap-java/cds-ai/issues/110)). + +### Key Infrastructure Classes + +| Class | Role | +|---|---| +| `RecommendationConfiguration` | extends `CdsRuntimeConfiguration` — wires all handlers at startup; selects production vs. mock based on AI Core binding presence | +| `FioriRecommendationHandler` | `@After` read handler on all app services (`entity="*"`) — cross-cutting read interceptor; orchestrates the full recommendation pipeline | +| `RecommendationContextBuilder` | Reads CDS annotations to determine which fields are prediction targets and which columns supply training context | +| `RptModelSpec` | Static factory for the `ModelDeploymentSpec` targeting `sap-rpt-1-small`; used as the cache key for deployment resolution | +| `RptInferenceClient` | Calls RPT-1 `/predict` endpoint; handles the synthetic `SAP_RECOMMENDATIONS_ID` index column for composite/non-string keys | +| `RecommendationResultParser` | Type-coerces RPT-1 string output back to CDS primitive types; resolves `@Common.Text` descriptions from the database | +| `RecommendationModelChangedHandler` | `@On(EVENT_MODEL_CHANGED)` — invalidates per-tenant entity cache on MTX model upgrade | + +### Multi-Tenancy + +→ [Multi-Tenancy in cds-feature-ai-core README](../../cds-feature-ai-core/README.md#multi-tenancy) + +Tenant isolation is inherited from `cds-feature-ai-core`: each prediction call resolves the resource group and deployment for the current request's tenant. No additional MT configuration is required in this module. + +**Per-tenant entity cache** (in `FioriRecommendationHandler`): + +``` +Cache<":", Boolean> 10k max, no TTL + → entities with no prediction columns are recorded and skipped on every future read + → invalidated by RecommendationModelChangedHandler on model change +``` + +The cache is keyed by `:` and is invalidated on `ExtensibilityService.EVENT_MODEL_CHANGED` — ensuring that model upgrades (which may add or remove value-list annotations) are reflected without a restart, see also [MTX Model Change — Cache Invalidation](#mtx-model-change--cache-invalidation). This cache might be replaced with #129. + +Currently the cache stores only **misses** — entities that have no prediction columns. For entities that *do* have prediction columns, `RecommendationContextBuilder` re-derives them from the CDS model on every request. An alternative design would cache `Set` (the prediction column names) instead of `Boolean`, using an empty set for the no-prediction case. This would eliminate the per-request model scan for all entities, at the cost of a slightly larger cache value. + +### Key Flows + +#### Recommendation Pipeline (OData GET on draft entity) + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% +flowchart TD + classDef process fill:#fff,stroke:#000,color:#000 + classDef skip fill:#f4f4f4,stroke:#000,color:#000 + + START@{ shape: sm-circ, label: "Small start" } --> A(["OData GET — IsActiveEntity=false"]):::process + A --> B(["FioriRecommendationHandler @After(entity='*') afterRead(...)"]):::process + B --> C{In skip cache?} + C -->|yes| C1(["skip"]):::process + C1 --> L(["return response"]):::skip + C -->|no| D{Single row?} + D -->|no| L + D -->|yes| E(["identify prediction fields and context columns"]):::process + E --> F{Has prediction fields?} + F -->|no| F1(["add to skip cache"]):::process + F1 --> L + F -->|yes| G(["read context rows from DB"]):::process + G --> I(["predict recommendation values using RPT-1"]):::process + I --> J(["do type conversion and resolve @Common.Text descriptions"]):::process + J --> K(["add recommendation values to response row"]):::process + K --> L + L --> END1@{ shape: framed-circle, label: "Stop" } + + style START fill:#000,stroke:#000,color:#000 + style END1 fill:#fff,stroke:#000,stroke-width:3px,color:#000 + +``` + +##### Context row selection + +Context rows are fetched via `RecommendationContextBuilder.buildContextQuery()` directly against the `PersistenceService` (bypassing the application service layer — see [authorization note](#context-rows-and-instance-based-authorization) below). The query selects all non-draft, non-computed, non-readonly scalar columns of the same entity, filtered to rows where **all prediction columns are non-null** — rows that already have values for the fields being predicted. The current row is implicitly excluded because it still has null prediction values. Results are ordered by the most-recently-updated column (`@cds.on.update`) descending, or by key as fallback, and capped at `cds.ai.recommendations.contextRowLimit` (default 2000). + +This means selection is **recency-based, not similarity-based**: the model receives the most recently modified existing records as training context, not records that are semantically "near" the row being predicted. + +Context rows are **not cached** — every prediction fires a fresh database query. + +Context rows are currently fetched via `PersistenceService` (direct DB access), bypassing the application service and any instance-based authorization checks. This means a user could receive recommendations trained on rows they would not be allowed to read through the application service. +See: #128. + +#### MTX Model Change — Cache Invalidation + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': {'edgeLabelBackground': '#ffffff00', 'fontSize': '14px', 'primaryColor': '#fff', 'primaryBorderColor': '#000', 'primaryTextColor': '#000'}}}%% +flowchart TD + classDef process fill:#fff,stroke:#000,color:#000 + + START@{ shape: sm-circ, label: "start" } --> A(["ExtensibilityService fires EVENT_MODEL_CHANGED (tenantId)"]):::process + A --> B(["RecommendationModelChangedHandler @On(EVENT_MODEL_CHANGED)"]):::process + B --> C(["evict all skip cache entries for tenantId"]):::process + C --> END1@{ shape: framed-circle, label: "stop" } + + style START fill:#000,stroke:#000,color:#000 + style END1 fill:#000,stroke:#000,stroke-width:3px,color:#000 +``` + +--- + +## Tests + +Unit tests for `FioriRecommendationHandler`, `RptInferenceClient`, and `RecommendationConfiguration` live in `src/test/` within this module (`mvn test`). + +End-to-end integration tests covering the full recommendation pipeline against a real AI Core instance live in [`integration-tests/`](../../integration-tests/README.md) in the outer module (`RecommendationTest`, `NonStandardKeyRecommendationTest`). + +--- + +## Quality Tools + +→ [CI Checks and static analysis](../../CONTRIBUTING.md#ci-checks) + +--- + +## Architecture Decisions + +### Annotation-driven activation + +**Context:** Recommendations need to work across any CAP application that has value-list fields on draft-enabled entities, without requiring application developers to write handler code or configure anything beyond the CDS model annotations they already need for Fiori value help. + +**Decision:** Annotation-driven activation. `FioriRecommendationHandler` registers as an `@After(entity="*")` handler on all application services and derives prediction targets from the CDS model on each request. The `@cap-js/ai` Node.js CDS plugin adds the `SAP_Recommendations` navigation property to the model at build time so the predictions are serialized in the OData response without application changes. The trade-off is less flexibility — application code cannot currently override the inference call or observe the raw prediction result — tracked in [#110](https://github.com/cap-java/cds-ai/issues/110). + +--- + +### Recency-based context row selection + +**Context:** RPT-1 is a tabular prediction model that learns patterns from example rows (context rows) provided alongside the row to predict. The quality of predictions depends on the relevance of the context. Fetching all rows is impractical for large tables, and most models als impose a limit on the context rows (e.g. 2048 for SAP-RPT-1 https://help.sap.com/docs/sap-ai-core/generative-ai/sap-rpt-1#sap-rpt-models). + +**Solutions considered:** +- **Similarity-based selection** — select rows most semantically similar to the current row (e.g. by embedding distance or matching field values). This would select a better training context but requires additional infrastructure and adds much more complexity. +- **Recency-based selection (most recently modified first)** — orders by `@cds.on.update` descending, capped at `cds.ai.recommendations.contextRowLimit` (default 2000). Favors the most up-to-date data and requires no additional infrastructure. +**Decision:** Recency-based selection. The assumption is that recent records reflect the current state of the data better than older ones, making them more representative training context for the current user's editing patterns. Similarity-based selection remains a possible future improvement ([#128](https://github.com/cap-java/cds-ai/issues/128)) but was rejected for the initial version due to infrastructure requirements. + +--- + +### Miss-only entity skip cache + +**Context:** `RecommendationContextBuilder` derives prediction columns by scanning the CDS model on every request. For entities with no value-list fields, this scan is repeated on every OData GET — wasteful, since the model only changes on MTX upgrades. + +**Solutions considered:** +- **No cache** — simple but scans the model on every request for every entity, including those that will never have predictions. +- **Cache misses only (`Boolean` flag)** — entities with no prediction columns are cached; entities with columns are re-scanned every request. Small cache, but doesn't help for the common case of entities that do have predictions. +- **Cache the prediction column set (`Set`)** — cache the derived column names for all entities, using an empty set for no-prediction entities. Eliminates the per-request model scan entirely; slightly larger cache values. + +**Decision:** Cache misses only for now (`Cache`). The per-request model scan for entities that *do* have predictions was accepted as acceptable overhead in the initial version. Caching `Set` instead is tracked as a follow-up in [#129](https://github.com/cap-java/cds-ai/issues/129). The cache is keyed by `:` and invalidated on `EVENT_MODEL_CHANGED` so MTX model upgrades are reflected without a restart.