diff --git a/cds-feature-ai-core/docs/architecture.md b/cds-feature-ai-core/docs/architecture.md new file mode 100644 index 0000000..13cd192 --- /dev/null +++ b/cds-feature-ai-core/docs/architecture.md @@ -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. + +→ [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 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 +} +``` + +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 + +##### Event 1: resourceGroup + +```mermaid +flowchart TD + A1["emit ResourceGroupContext (tenantId)"] + A1 --> A2{"multiTenancy enabled
AND tenantId != null?"} + A2 -->|no| A3["return config.defaultResourceGroup()"] + A2 -->|yes| A4{"tenantResourceGroupCache
lookup by tenantId"} + A4 -->|cache hit| A5["return cached resourceGroupId"] + A4 -->|cache miss| A6["GET /v2/admin/resourceGroups
labelSelector: ext.ai.sap.com/tenant={tenantId}"] + A6 --> A7{"found?"} + A7 -->|yes| A8["cache result (expireAfterAccess 1h)"] + A7 -->|no| A9["POST /v2/admin/resourceGroups
(handle 409 Conflict = already exists)"] + A9 --> A8 + A8 --> A5 +``` + +##### Event 2: deploymentId — invoked with `resourceGroupId` + +```mermaid +flowchart TD + B1["emit DeploymentIdContext
(resourceGroupId, ModelDeploymentSpec)"] + B1 --> B2["acquire per-key lock
(ConcurrentHashMap)"] + B2 --> B3{"deploymentCache
lookup by rgId::configName"} + B3 -->|cache hit| B4["validateCachedDeployment:
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):
GET /v2/lm/deployments?scenarioId=..."] + B9 --> B10{"match by configName
+ matchesExisting() + RUNNING/PENDING?"} + B10 -->|found| B11["cache deploymentId (expireAfterAccess 1h)"] + B10 -->|not found| B12["findOrCreateConfiguration:
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:
GET /v2/lm/deployments/{id}
(exponential backoff)"] + B17 --> B11 + B11 --> B6 +``` + +*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
(resourceGroupId, deploymentId)"] + C1 --> C2["clients.sdkService()
.getInferenceDestination(rgId)
.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) diff --git a/cds-feature-recommendations/docs/architecture.md b/cds-feature-recommendations/docs/architecture.md new file mode 100644 index 0000000..b740d57 --- /dev/null +++ b/cds-feature-recommendations/docs/architecture.md @@ -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. + +→ [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). | +| `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). + +### 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 +``` + +### 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 + +``` +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](../../CONTRIBUTING.md#ci-checks)