Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions cds-feature-ai-core/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# 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)

---

## 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 standard CAP `RemoteService`. 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a standard CAP RemoteService

"as a CAP service"

A remote service bridges to a RESTish remote service that somehow understands CRUD operations


→ [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 SDK directly. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| `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 SDK directly. |
| `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. |

---

## 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

// Events:
event resourceGroup // in: (optional: tenantId — falls back to UserInfo.getTenant() from request context) → out: resourceGroupId
event deploymentId // in: resourceGroupId + ModelDeploymentSpec → out: deploymentId
event inferenceClient // in: resourceGroupId + deploymentId → out: ApiClient
Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't find these "events" in cds-feature-ai-core/src/main/resources/cds/com.sap.cds/ai/index.cds.

}
```

Entities are `@cds.persistence.skip` — they have no database tables and are backed entirely by the AI Core REST API at runtime.

---

### Public API

→ [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)

---

### Key Flows

#### Tenant Subscribe

```
CAP MTX DeploymentService
|
| SubscribeEvent @After(LATE)
v
AICoreSetupHandler
|
| resolveResourceGroup(tenantId)
v
DeploymentResolver
|
| GET /v2/admin/resourceGroups?labelFilter=CDS_TENANT_ID=tenantId
v
SAP AI Core
|
| (if absent) POST /v2/admin/resourceGroups
v
resourceGroupId (cached 1h after last access — subsequent calls skip the AI Core management API;
if a resource group is deleted or reassigned externally, the plugin won't notice until the cache expires after 1h or the app restarts)
```

#### Inference Client Resolution

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a textual description of which problem you are solving here, why and how.

##### Event 1: resourceGroup

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please describe in prose what this event is. What is the purpose of this event? Who is emitting this event? ...


```mermaid
flowchart TD
A1["emit ResourceGroupContext (tenantId)"]
A1 --> A2{"multiTenancy enabled<br>AND tenantId != null?"}
A2 -->|no| A3["return config.defaultResourceGroup()"]
A2 -->|yes| A4{"tenantResourceGroupCache<br>lookup by tenantId"}
A4 -->|cache hit| A5["return cached resourceGroupId"]
A4 -->|cache miss| A6["GET /v2/admin/resourceGroups<br>labelSelector: ext.ai.sap.com/tenant={tenantId}"]
A6 --> A7{"found?"}
A7 -->|yes| A8["cache result (expireAfterAccess 1h)"]
A7 -->|no| A9["POST /v2/admin/resourceGroups<br>(handle 409 Conflict = already exists)"]
Comment on lines +126 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I roughly understand that you ask the AI SDK's REST API whether there is a resource group for a given tenant. If absent you create a resource group.

If this is just "create if absent" I you could just describe it in a single sentence. The diagram is overkill, I think.

A9 --> A8
A8 --> A5
```

##### Event 2: deploymentId — invoked with `resourceGroupId`

```mermaid
flowchart TD
B1["emit DeploymentIdContext<br>(resourceGroupId, ModelDeploymentSpec)"]
B1 --> B2["acquire per-key lock<br>(ConcurrentHashMap)"]
B2 --> B3{"deploymentCache<br>lookup by rgId::configName"}
B3 -->|cache hit| B4["validateCachedDeployment:<br>GET /v2/lm/deployments/{id}"]
B4 --> B5{"status RUNNING or PENDING?"}
B5 -->|yes| B6["return cached deploymentId"]
B5 -->|no / 404| B7["invalidate cache entry"]
B7 --> B8
B3 -->|cache miss| B8["findOrCreateDeployment (under lock)"]
B8 --> B9["queryDeploymentsUntilReady (with retry):<br>GET /v2/lm/deployments?scenarioId=..."]
B9 --> B10{"match by configName<br>+ matchesExisting() + RUNNING/PENDING?"}
B10 -->|found| B11["cache deploymentId (expireAfterAccess 1h)"]
B10 -->|not found| B12["findOrCreateConfiguration:<br>GET /v2/lm/configurations?scenarioId=..."]
B12 --> B13{"config with matching name exists?"}
B13 -->|yes| B14["reuse existing configId"]
B13 -->|no| B15["POST /v2/lm/configurations"]
B15 --> B14
B14 --> B16["POST /v2/lm/deployments (with retry for 403/412)"]
B16 --> B17["pollUntilRunning:<br>GET /v2/lm/deployments/{id}<br>(exponential backoff)"]
B17 --> B11
B11 --> B6
Comment on lines +137 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You look up a deployment. If it's not existing you create a new one. As the mechanism is asynchronous you poll in a loop with exponential back-off.

Is this what's going on here?

```
Comment thread
lisajulia marked this conversation as resolved.

*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`).*

##### Event 3: inferenceClient — invoked with `resourceGroupId` and `deploymentId`

```mermaid
flowchart TD
C1["emit InferenceClientContext<br>(resourceGroupId, deploymentId)"]
C1 --> C2["clients.sdkService()<br>.getInferenceDestination(rgId)<br>.usingDeploymentId(depId)"]
C2 --> C3["return ApiClient.create(destination)"]
```


#### Tenant Unsubscribe

```
CAP MTX DeploymentService
|
| UnsubscribeEvent @Before(EARLY)
v
AICoreSetupHandler
|
| DELETE /v2/admin/resourceGroups/{id}
v
SAP AI Core
|
| invalidateTenant(tenantId) — evicts tenantResourceGroupCache and deploymentCache (which was filled on first call to resolveDeployment) entries for this tenant
v
(done)
```
---

## 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)
132 changes: 132 additions & 0 deletions cds-feature-recommendations/docs/architecture.md

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open questions for me are:

  • how are the context rows selected? They should be somehow "near"/"similar" to the row for which the data is predicted, I think. But how is this similarity determined?
  • or could we cache the context rows and reuse them for further predictions?
  • @BraunMatthias also made the point that we should fetch the context rows through the application service to ensure that we don't bypass instance-based authorization -> please add this to the TODOs/BLIs

Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# 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)
- [MTX Model Change — Cache Invalidation](#mtx-model-change--cache-invalidation)
- [Tests](#tests)
- [Quality Tools](#quality-tools)

---

## Purpose

Automatically injects AI-powered field recommendations from the SAP RPT-1 tabular prediction foundation model into Fiori Elements OData responses for draft-enabled entities. Zero application code required.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider to link to RPT-1 docs


→ [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). |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here for this document it's perfect to describe the current state.

As you have a dependency to cds-services-utils you could consider to rather use com.sap.cds.services.utils.TenantAwareCache, which would free the plugin from all concerns wrt. tenant-specific cache invalidation.

Please add a section with left-overs/todos/blis/...

| `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, see [`README`](../README.md#enabling-recommendations).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

annotated with a value list

please quote the annotation name although you link to the read.me


### 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)).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move or add this to a BLIs/TODOs/... section

I think a CAPish/Calesi-ish approach would to introduce a RecomendationService. A default implementation would use RPT-1. A mock implementation could generate something random.

You could handle the RecomendationService on-event to provide your own implementation. Or in an after-handler fine-tune the result.


### 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think, later we can use the TenantAwareCache instead.


### 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<"<tenantId>:<entityName>", Boolean> 10k max, no TTL
→ entities with no prediction columns are recorded and skipped on every future read
→ invalidated by RecommendationModelChangedHandler on model change
```

### Key Flows

#### Recommendation Pipeline (OData GET on draft entity)

```mermaid
flowchart TD

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice to have swim lanes for

  • app server
  • AICore/RPT-1
  • the database

(but I am afraid the flowchart diagram does not support swim lanes)

A["OData GET — IsActiveEntity=false"] --> B["FioriRecommendationHandler @After(entity='*') afterRead(...)"]
B --> C{Entity in no-prediction cache?}
C -->|yes — skip| Z["Return response unchanged"]
C -->|no| D{Draft row? Single result?}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Draft row?

I think we have asserted this already.

D -->|no| Z
D -->|yes| E["RecommendationContextBuilder: identify prediction fields + context columns"]
E --> F{Does this entity have any prediction fields?}
F -->|no — add entity to skip cache| Z

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same (?) cache is called "no-prediction cache" above

F -->|yes| G["DB query: up to 2000 context rows (ORDER BY modifiedAt DESC)"]
G --> H["cds-feature-ai-core: resolveResourceGroup → resolveDeploymentId → inferenceClient"]
H --> I["RptInferenceClient.predict(predictRow, contextRows, columns) POST /v2/inference/deployments/{id}/predict"]
I --> J["RecommendationResultParser: type-convert + resolve @Common.Text descriptions"]
J --> K["Inject SAP_Recommendations into response row"]
Comment on lines +98 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of details here. Keep this briefer (as suggested below)

K --> L["Return enriched response"]
Comment on lines +89 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider to make the diagram more similar to a TAM activity diagram

Suggested change
flowchart TD
A["OData GET — IsActiveEntity=false"] --> B["FioriRecommendationHandler @After(entity='*') afterRead(...)"]
B --> C{Entity in no-prediction cache?}
C -->|yes — skip| Z["Return response unchanged"]
C -->|no| D{Draft row? Single result?}
D -->|no| Z
D -->|yes| E["RecommendationContextBuilder: identify prediction fields + context columns"]
E --> F{Does this entity have any prediction fields?}
F -->|no — add entity to skip cache| Z
F -->|yes| G["DB query: up to 2000 context rows (ORDER BY modifiedAt DESC)"]
G --> H["cds-feature-ai-core: resolveResourceGroup → resolveDeploymentId → inferenceClient"]
H --> I["RptInferenceClient.predict(predictRow, contextRows, columns) POST /v2/inference/deployments/{id}/predict"]
I --> J["RecommendationResultParser: type-convert + resolve @Common.Text descriptions"]
J --> K["Inject SAP_Recommendations into response row"]
K --> L["Return enriched response"]
flowchart TD
START@{ shape: sm-circ, label: "Small start" } -->
A(["OData GET — IsActiveEntity=false"])
A --> B(["FioriRecommendationHandler @After(entity='*') afterRead(...)"])
B --> C{in no-prediction cache?}
C -->|yes — skip| Z(["Return response unchanged"])
C -->|no| D{Draft row? Single result?}
D -->|no| Z
D -->|yes| E(["RecommendationContextBuilder: identify prediction fields + context columns"])
E --> F{has prediction fields?}
F -->|no — add entity to skip cache| Z
F -->|yes| G(["DB query: up to 2000 context rows (ORDER BY modifiedAt DESC)"])
G --> H(["cds-feature-ai-core: resolveResourceGroup → resolveDeploymentId → inferenceClient"])
H --> I(["RptInferenceClient.predict(predictRow, contextRows, columns) POST /v2/inference/deployments/{id}/predict"])
I --> J(["RecommendationResultParser: type-convert + resolve @Common.Text descriptions"])
J --> K(["Inject SAP_Recommendations into response row"])
K --> L(["Return enriched response"])
L --> END1@{ shape: framed-circle, label: "Stop" }
Z --> END2@{ shape: framed-circle, label: "Stop" }

or, maybe:

Suggested change
flowchart TD
A["OData GET — IsActiveEntity=false"] --> B["FioriRecommendationHandler @After(entity='*') afterRead(...)"]
B --> C{Entity in no-prediction cache?}
C -->|yes — skip| Z["Return response unchanged"]
C -->|no| D{Draft row? Single result?}
D -->|no| Z
D -->|yes| E["RecommendationContextBuilder: identify prediction fields + context columns"]
E --> F{Does this entity have any prediction fields?}
F -->|no — add entity to skip cache| Z
F -->|yes| G["DB query: up to 2000 context rows (ORDER BY modifiedAt DESC)"]
G --> H["cds-feature-ai-core: resolveResourceGroup → resolveDeploymentId → inferenceClient"]
H --> I["RptInferenceClient.predict(predictRow, contextRows, columns) POST /v2/inference/deployments/{id}/predict"]
I --> J["RecommendationResultParser: type-convert + resolve @Common.Text descriptions"]
J --> K["Inject SAP_Recommendations into response row"]
K --> L["Return enriched response"]
flowchart TD
START@{ shape: sm-circ, label: "Small start" } -->
A(["OData GET — IsActiveEntity=false"])
A --> B(["FioriRecommendationHandler @After(entity='*') afterRead(...)"])
B --> C{in skip cache?}
C -->|yes — skip| L(["return response"])
C -->|no| D{single row?}
D -->|no| L
D -->|yes| E(["identify prediction fields and context columns"])
E --> F{has prediction fields?}
F -->|no| F1(["add to skip cache"])
F1 --> L
F -->|yes| G(["read context rows from DB"])
G --> I(["predict recommendation values using RPT-1"])
I --> J(["do type conversion and resolve @Common.Text descriptions"])
J --> K(["add recommendation values to response row"])
K --> L
L --> END1@{ shape: framed-circle, label: "Stop" }

There's a helpful playground here: https://mermaid.ai/live/edit

```

#### No-prediction-cache Invalidation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be a good place to discuss the no-prediction-cache in general.

Currently, you only cache misses, i.e. entities for which no recommendations are calculated. But if an entity is not in the "skip-cache" you need to recompute the recommendation columns over and over again.

I think it would be better to just to cache the "recommendation columns"/"prediction fields". You could compute these if absent in the cache. If the entity does not support recommendations you'd just cache an empty set to indicate a skip/miss. The downside would be a larger cache, of course.


```
ExtensibilityService
|
| EVENT_MODEL_CHANGED (tenantId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I have recommended leaning on the EVENT_MODEL_CHANGED earlier but I think using a TenantAwareCache would be more straightforward.

v
RecommendationModelChangedHandler
|
| evict all entries in no-prediction-cache for tenantId
v
Next read re-evaluates
```

---

## 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)
Loading