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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
- The format is based on [Keep a Changelog](https://keepachangelog.com/).
- This project adheres to [Semantic Versioning](https://semver.org/).


## Version 1.1.0 - Upcoming

### Added

- New `@UI.RecommendationState` opt-in annotation for scalar fields without a value help (free-form numerics, free-text). Annotated fields are added to the entity's `<Entity>_Recommendations` companion alongside value-helped fields. RPT-1 `task_type` is selected per column: numeric scalars opted in via `@UI.RecommendationState` use `regression` so the model can interpolate continuous values; everything else uses `classification` (existing behaviour). The `task_type` enum on `predictRowColumns` is widened from `{classification}` to `{classification, regression}`.

### Fixed
- CDS-to-RPT-1 dtype map now matches the inference API's enum (`'string' | 'numeric' | 'date'`). Previously emitted `'bool'` for `cds.Boolean` and `'datetime'` for `cds.DateTime` / `cds.Timestamp`, causing HTTP 422 from `/predict` for any entity carrying those types. `cds.DateTime` and `cds.Timestamp` now map to `'string'` so the full ISO value is preserved as an opaque token (no time loss, no date-parse rejection).
- Composition children of draft-enabled entities are now reliably enhanced. The CSN enhancer previously only walked `entity.compositions` — a legacy CSN shape — and missed the `entity.elements[*]` form used by current CDS, with the result that nested entities (e.g. `Approvers` under a `ChangeRequests` draft) never received `@UI.Recommendations` and so never got recommendations even when their fields had value lists. The walk is now recursive through both shapes with cycle protection.
- Empty-rows crash in `_fetchPrediction`. Reading a draft entity whose composition was empty fed `rows: []` to the prediction API, and the schema-derivation reduce dereferenced `rows[0][ele]` on `undefined`, taking the whole server down on a TypeError. `_fetchPrediction` now returns an empty result for empty input. The READ handler also short-circuits when the response set is empty, avoiding a needless AI Core round-trip.
- RPT-1 inference limits now honoured. When `target_columns.length > 10` or when a row carries more than 100 columns, `_fetchPrediction` logs a warning and returns an empty result instead of letting the API reject the request with a noisy 422. The warning includes a hint to use `@UI.RecommendationState : 0` to opt fields out and bring the count down.

## Version 1.0.1 - 2026-05-08

### Fixed
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,53 @@ annotate Books with {
}
```

#### Recommendations on fields without a value help

By default the plugin only enhances fields that have a value list — that's how it auto-detects which columns are good prediction targets. Some fields are good targets but have no value list: free-form numerics like measurement ranges, calibration values, or planning estimates. Annotate these with `@UI.RecommendationState : 1` to opt in:

```cds
entity CalibrationData : cuid {
measuringRangeMin : Decimal(16, 6) @UI.RecommendationState : 1;
measuringRangeMax : Decimal(16, 6) @UI.RecommendationState : 1;
operatingPoint : Decimal(16, 6) @UI.RecommendationState : 1;
description : String @UI.RecommendationState : 1;
}
```

The annotation only takes effect on **scalar** elements (no associations / compositions / unmanaged elements; for those, attach a value help instead). Annotated fields are added to the entity's `<Entity>_Recommendations` companion just like value-helped fields, and Fiori Elements' soft-fill placeholder renders the prediction in the empty input.

`task_type` is chosen automatically per column:
- numeric scalar (`Integer*`, `Decimal`, `Double`) annotated with `@UI.RecommendationState` → **`regression`** so RPT-1 can interpolate continuous values,
- everything else → **`classification`**.

> [!NOTE]
> Numeric fields that have a value help (e.g. a fixed price-point list) stay on classification — `@UI.RecommendationState` is only needed when there is *no* value help. Combining both is unnecessary.

#### How recommendations work under the hood

A short FAQ for integrators, so you don't have to read the source.

**What does the plugin emit on the OData service?**
On every draft-enabled entity that has at least one value-helped field, it adds an entity-level annotation `@UI.Recommendations: { '=': 'SAP_Recommendations' }` plus a synthetic companion entity (`<Entity>_Recommendations`, `@cds.persistence.skip`) with one virtual array per recommendable field. Each item carries `RecommendedFieldValue`, `RecommendedFieldDescription`, `RecommendedFieldScoreValue` and `RecommendedFieldIsSuggestion` — the shape Fiori Elements expects for `UI.RecommendationListType`. The first entry per field has `RecommendedFieldIsSuggestion: true` and is rendered as the soft-fill default.

**When does it run?**
On READ requests to a draft entity that expand `SAP_Recommendations`. Reads against the active entity return nothing in that field. Reads during `draftActivate` are skipped.

**What data is sent to RPT-1 as context?**
Up to 2000 rows from the **active** version of the same entity, restricted to rows where every recommendable field is non-null. The columns `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` plus any `cds.LargeBinary` / `cds.Vector` elements are stripped. The active row corresponding to the draft (if any) is removed and replaced by the draft row carrying `[PREDICT]` placeholders in the columns to predict. There is no sampling or `ORDER BY` — for tables larger than 2000 rows, which rows make the cut is determined by the database.

> [!IMPORTANT]
> Everything in the remaining columns is forwarded to AI Core. Annotate sensitive fields with `@UI.RecommendationState : 0` (or a dynamic expression) to keep them out of both the predictions and the context payload.

**How are descriptions populated?**
For each predicted value, the plugin issues an extra SELECT against the field's `@Common.Text` association (if set) to fetch the human-readable label. Fields without `@Common.Text` get an empty `RecommendedFieldDescription`.

**RPT-1 deployment lifecycle**
First prediction call against a resource group provisions an `sap-rpt-1-small` deployment in scenario `foundation-models` (executable `aicore-sap`) and polls up to 10× with exponential backoff until it reaches `RUNNING`. Subsequent calls reuse the cached deployment. Single-tenant uses the configured `resourceGroup` (default `'default'`); multi-tenant creates one resource group per tenant on subscribe (label `ext.ai.sap.com/CDS_TENANT_ID`) and deletes it on unsubscribe.

**Local development**
Without an AI Core binding the plugin uses `MockAICoreService`, which returns the first non-null value of each target column from the context as the "prediction" — useful for UI smoke tests, useless as a quality signal. Run `cds bind <your-aicore-instance>` and start with profile `hybrid` to talk to a real AI Core deployment locally.

### 2. Use case: Simplified AI Core usage

The plugin introduces an `AICore` CAP service that automatically performs some administrative tasks and offers simplified access to AI Core.
Expand Down
66 changes: 53 additions & 13 deletions lib/csn-enhancements/recommendations.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,39 @@ function enhanceModel(model) {
continue;
}
enhanceEntity(name, model);
if (entity.compositions) {
for (const comp in entity.compositions) {
enhanceEntity(entity.compositions[comp].target, model);
// Walk composition children recursively. In legacy CSN flavours the
// compositions are exposed under `entity.compositions`; in current
// (CDS >= 7) CSN they are part of `entity.elements` with
// `type === 'cds.Composition'`. enhanceEntity itself is idempotent
// (it bails if `@UI.Recommendations` is already set), so duplicate
// visits are safe; the visited set is just a micro-optimisation and
// a guard against composition cycles.
const visited = new Set([name]);
const walk = (entityName) => {
const e = model.definitions[entityName];
if (!e) return;
if (e.compositions) {
for (const comp in e.compositions) {
const tgt = e.compositions[comp].target;
if (tgt && !visited.has(tgt)) {
visited.add(tgt);
enhanceEntity(tgt, model);
walk(tgt);
}
}
}
}
if (e.elements) {
for (const ele in e.elements) {
const def = e.elements[ele];
if (def.type === 'cds.Composition' && def.target && !visited.has(def.target)) {
visited.add(def.target);
enhanceEntity(def.target, model);
walk(def.target);
}
}
}
};
walk(name);
}
model.meta[enhancedFlag] = true;
}
Expand All @@ -27,22 +55,34 @@ function enhanceEntity(name, model) {
const entity = model.definitions[name];
if (entity['@UI.Recommendations']) return; // already enhanced
const vhFields = Object.keys(entity.elements).reduce((vhFields, ele) => {
// check if the property has a value help
const def = entity.elements[ele];
if (def['@UI.RecommendationState'] === 0) return vhFields;
// 1) Auto-detected: property has a value help.
const hasValueHelp =
entity.elements[ele]['@Common.ValueList.CollectionPath'] ||
model.definitions[entity.elements[ele].target]?.['@cds.odata.valuelist'];
if (entity.elements[ele]['@UI.RecommendationState'] !== 0 && hasValueHelp) {
if (entity.elements[ele].keys) {
for (const key of entity.elements[ele].keys) {
def['@Common.ValueList.CollectionPath'] ||
model.definitions[def.target]?.['@cds.odata.valuelist'];
if (hasValueHelp) {
if (def.keys) {
for (const key of def.keys) {
vhFields[ele + '_' + key.ref.join('_')] = structuredClone(
model.definitions[entity.elements[ele].target].elements[key.ref]
model.definitions[def.target].elements[key.ref]
);
delete vhFields[ele + '_' + key.ref.join('_')].key;
}
} else if (!entity.elements[ele].on) {
vhFields[ele] = structuredClone(entity.elements[ele]);
} else if (!def.on) {
vhFields[ele] = structuredClone(def);
delete vhFields[ele].key;
}
return vhFields;
}
// 2) Opt-in: scalar field annotated `@UI.RecommendationState` without a value
// help. Lets RPT-1 predict free-form values (typically numeric) for
// fields where Fiori Elements' soft-fill placeholder is the desired
// UX. Skips associations / compositions / unmanaged elements — those
// are handled by the value-help branch above.
if (def['@UI.RecommendationState'] && !def.target && !def.on) {
vhFields[ele] = structuredClone(def);
delete vhFields[ele].key;
}
return vhFields;
}, {});
Expand Down
4 changes: 4 additions & 0 deletions lib/handlers/recommendations.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export default function registerHandlersForRecommendations(srv) {
if (!res) return res;
const fieldsWithDisabledRecommendations = {};
const response = Array.isArray(res) ? res : [res];
// No subjects means nothing to predict for. Returning early avoids an
// empty-rows round-trip to AI Core (which would also be a degenerate
// input for RPT-1).
if (response.length === 0) return res;
const aiCore = await cds.connect.to('AICore');
const contextRows = records.filter((r) => !response.some((rr) => matchRow(r, rr)));
for (const row of response) {
Expand Down
3 changes: 2 additions & 1 deletion srv/AICoreService.cds
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ service AICore {
name : String;
prediction_placeholder : String;
task_type : String enum {
classification = 'classification'
classification = 'classification';
regression = 'regression';
}
}
},
Expand Down
68 changes: 64 additions & 4 deletions srv/AICoreService.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { createConfiguration, readConfigurations } from './ai-core/configuration

const LOG = cds.log('@cap-js/ai');

// RPT-1 inference only accepts dtype values 'string', 'numeric' or 'date'.
// - Booleans round-trip as 'true' / 'false' strings.
// - cds.Date maps to 'date' (calendar date, no time portion).
// - cds.DateTime / cds.Timestamp keep their time portion: declared as
// 'string' so RPT-1 treats the ISO value as an opaque categorical token
// instead of date-parsing it (which would drop the time and may reject
// ISO timestamps that aren't pure YYYY-MM-DD).
const CDS_TO_PYTHON_DTYPE = {
'cds.String': 'string',
'cds.LargeString': 'string',
Expand All @@ -32,17 +39,51 @@ const CDS_TO_PYTHON_DTYPE = {
'cds.UInt8': 'numeric',
'cds.Decimal': 'numeric',
'cds.Double': 'numeric',
'cds.Boolean': 'bool',
'cds.Boolean': 'string',
'cds.Date': 'date',
'cds.Time': 'string',
'cds.DateTime': 'datetime',
'cds.Timestamp': 'datetime'
'cds.DateTime': 'string',
'cds.Timestamp': 'string'
};

function cdsToPythonDtype(cdsType) {
return CDS_TO_PYTHON_DTYPE[cdsType];
}

const NUMERIC_CDS_TYPES = new Set([
'cds.Integer',
'cds.Integer64',
'cds.Int16',
'cds.Int32',
'cds.Int64',
'cds.UInt8',
'cds.Decimal',
'cds.Double'
]);

// Pick the RPT-1 task type per target column. Fields with a ValueList always
// get `classification` — they represent categorical choices. Numeric scalars
// opted in via `@UI.RecommendationState` (without a ValueList) get `regression`
// so the model can interpolate continuous values; everything else gets
// `classification`.
function pickTaskType(entity, columnName) {
const ele = entity?.elements?.[columnName];
if (!ele) return 'classification';
const hasValueList =
ele['@Common.ValueList.CollectionPath'] ||
cds.model.definitions[ele.target]?.['@cds.odata.valuelist'];
if (hasValueList) return 'classification';
if (ele['@UI.RecommendationState'] && NUMERIC_CDS_TYPES.has(ele.type)) return 'regression';
return 'classification';
}

// RPT-1 inference limits, per
// https://help.sap.com/docs/sap-ai-core/generative-ai/sap-rpt-1
// Exceeding either causes HTTP 422; we warn and skip the prediction so the
// surrounding READ still completes instead of breaking the whole response.
const RPT1_MAX_TARGET_COLUMNS = 10;
const RPT1_MAX_ROW_COLUMNS = 100;

export default class AICore extends cds.ApplicationService {
init() {
this.on('fetchPredictions', this._fetchPrediction);
Expand Down Expand Up @@ -103,6 +144,25 @@ export default class AICore extends cds.ApplicationService {

async _fetchPrediction(req) {
const { rows, entity: entityName, predictionColumns } = req.data;
// Empty rows would crash the schema-derivation reduce (rows[0] is
// undefined). Happens routinely when a draft composition is being
// read with no active rows yet — there is nothing to predict from.
if (!rows?.length) return {};
if (predictionColumns.length > RPT1_MAX_TARGET_COLUMNS) {
LOG.warn(
`Skipping recommendations for ${entityName}: ${predictionColumns.length} target columns exceeds the RPT-1 limit of ${RPT1_MAX_TARGET_COLUMNS}. ` +
'Opt fields out via @UI.RecommendationState : 0 to bring the count down.'
);
return {};
}
const rowColumnCount = Object.keys(rows[0]).length;
if (rowColumnCount > RPT1_MAX_ROW_COLUMNS) {
LOG.warn(
`Skipping recommendations for ${entityName}: rows carry ${rowColumnCount} columns, exceeding the RPT-1 limit of ${RPT1_MAX_ROW_COLUMNS}. ` +
'Either narrow the entity projection or opt out @cds.api.ignore-style columns that are not useful as features.'
);
return {};
}
const entity = (cds.context?.model ?? cds.model).definitions[entityName];
const dataSchema = entity
? Object.keys(entity.elements).reduce((acc, ele) => {
Expand All @@ -123,7 +183,7 @@ export default class AICore extends cds.ApplicationService {
target_columns: predictionColumns.map((c) => ({
name: c,
prediction_placeholder: '[PREDICT]',
task_type: 'classification'
task_type: pickTaskType(entity, c)
}))
},
// SAP_RECOMMENDATIONS_ID is generated in case the entity has composed keys or a key not named ID
Expand Down
Loading