diff --git a/cds-feature-ai-core/docs/architecture.md b/cds-feature-ai-core/docs/architecture.md new file mode 100644 index 0000000..8569c50 --- /dev/null +++ b/cds-feature-ai-core/docs/architecture.md @@ -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) diff --git a/cds-feature-recommendations/docs/architecture.md b/cds-feature-recommendations/docs/architecture.md new file mode 100644 index 0000000..a43f221 --- /dev/null +++ b/cds-feature-recommendations/docs/architecture.md @@ -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<":", 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)