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
165 changes: 165 additions & 0 deletions cds-feature-ai-core/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# 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.

→ [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. |
| `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 eventually consistent. |
| 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

// 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
}
```

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

```mermaid
flowchart TD
A["aiCoreService.inferenceClient(rgId, deploymentId)"] --> B["DeploymentResolver: check tenantResourceGroupCache"]
B -->|cache hit| E["check deploymentCache"]
B -->|cache miss| C["GET /v2/admin/resourceGroups — find by tenantId label"]
C --> D["PUT in tenantResourceGroupCache (1h TTL)"]
D --> E
E -->|cache hit| V["validateCachedDeployment: GET /v2/lm/deployments/{id}"]
V -->|valid| H["emit InferenceClientContext → ApiClient"]
V -->|invalid — invalidate cache entry| F
E -->|cache miss| F["GET /v2/lm/deployments — match by ModelDeploymentSpec"]
F -->|not found| G["POST /v2/lm/configurations + POST /v2/lm/deployments — poll until RUNNING"]
F -->|found| I["PUT in deploymentCache (1h TTL)"]
G --> I
I --> H
```
*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`).*


#### 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 of outer module](../../README.md#ci-checks)
130 changes: 130 additions & 0 deletions cds-feature-recommendations/docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 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.

→ [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. |
| CAP Java `ExtensibilityService` | `RecommendationModelChangedHandler` listens to `EVENT_MODEL_CHANGED` to invalidate the per-tenant entity cache when a tenant's CDS model is upgraded via MTX. |

---

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

### 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<"<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
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"]
```

#### No-prediction-cache Invalidation

```
com.sap.cds.services.extensibility.ExtensibilityService
|
| EVENT_MODEL_CHANGED (tenantId)
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 of outer module](../../README.md#ci-checks)