From 0ebd5c78b7bb2d9b03c639500048fd59fa2579af Mon Sep 17 00:00:00 2001 From: Soeren Leibach Date: Wed, 29 Apr 2026 22:33:28 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20align=20CDS=E2=86=92RPT-1=20dtype=20?= =?UTF-8?q?map=20with=20API=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPT-1's /predict endpoint only accepts dtype values 'string', 'numeric' or 'date' and returns 422 for anything else. The map previously emitted 'bool' for cds.Boolean and 'datetime' for cds.DateTime / cds.Timestamp, which broke prediction for any entity carrying those types. - cds.Boolean: 'string' (round-trips as 'true' / 'false') - cds.DateTime / cds.Timestamp: 'string' so the full ISO value is kept as an opaque token (declaring 'date' would drop the time portion and may reject non-YYYY-MM-DD inputs) - cds.Date stays 'date' --- srv/AICoreService.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/srv/AICoreService.js b/srv/AICoreService.js index 482314c..ad27ffd 100644 --- a/srv/AICoreService.js +++ b/srv/AICoreService.js @@ -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', @@ -32,11 +39,11 @@ 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) { From 848f413b0fb7def4e55862ba8fd7c39912953527 Mon Sep 17 00:00:00 2001 From: Soeren Leibach Date: Wed, 29 Apr 2026 23:07:27 +0200 Subject: [PATCH 2/6] docs: add 'how it works' FAQ for integrators Short summary of the runtime behaviour so consumers don't have to read the source to answer the most common questions: what annotations get emitted, when the handler runs, what context is sent to RPT-1 (the 2000 active-rows cap, the column stripping, the [PREDICT] placeholder), how descriptions are filled, the RPT-1 deployment lifecycle, and the local mock behaviour. Includes a callout reminding integrators to opt sensitive fields out via @UI.RecommendationState. --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 2bd5311..44f14df 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,31 @@ annotate Books with { } ``` +#### 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 (`_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 ` 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. From 920691e109b39790cddd11dd4084d1839cdd3ce8 Mon Sep 17 00:00:00 2001 From: Soeren Leibach Date: Wed, 29 Apr 2026 23:45:40 +0200 Subject: [PATCH 3/6] fix: enhance composition children on current CDS CSN flavour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSN enhancer walked `entity.compositions[*]` only — a legacy CSN shape. In CDS >= 7 compositions live in `entity.elements[*]` with `type === 'cds.Composition'`, so the legacy walk was empty and nested entities (e.g. `Approvers` under a `ChangeRequests` draft) never received `@UI.Recommendations`. Recommendations would silently be missing from any field on a child of a draft root, even when the child's fields had perfectly good value lists. Replaced the single-level walk with a recursive walk that handles both the legacy `entity.compositions` map and the modern `entity.elements` form, with a visited set to break composition cycles. enhanceEntity is already idempotent, so a target reachable through both paths is enhanced once. --- CHANGELOG.md | 6 ++++- lib/csn-enhancements/recommendations.js | 36 ++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3305a4..a02058e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,8 @@ - Out of box support for recommended values in field helps in Fiori UIs by providing an `SAP_Recommendations` navigation property in OData services which contains the recommendations. - Provide a CAP `AICore` service, via which SAP AI Core artefacts can be queried, like 'resourceGroups', 'deployments' or 'configurations' with `cds.ql` (`SELECT.from(resourceGroups)` and alike). - Automatically create an AI Core deployment for SAP RPT-1 which is used for the recommended values in single tenant and multi tenant scenarios. -- Automatically creates an AI Core resource group per tenant in multi tenant scenarios. In single tenant mode the 'default' resource group is used. \ No newline at end of file +- Automatically creates an AI Core resource group per tenant in multi tenant scenarios. In single tenant mode the 'default' resource group is used. + +### 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. \ No newline at end of file diff --git a/lib/csn-enhancements/recommendations.js b/lib/csn-enhancements/recommendations.js index 5912b8a..2f7b617 100644 --- a/lib/csn-enhancements/recommendations.js +++ b/lib/csn-enhancements/recommendations.js @@ -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; } From 7a94d5b9e77dc8721db6a24f673a0f93101c7180 Mon Sep 17 00:00:00 2001 From: Soeren Leibach Date: Thu, 30 Apr 2026 12:29:41 +0200 Subject: [PATCH 4/6] feat: opt-in @AI.Recommend for fields without value helps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now, the CSN enhancer only emitted SAP_Recommendations for fields auto-detected as having a value help (`@Common.ValueList.CollectionPath` or `@cds.odata.valuelist` on the association target). Free-form scalar fields — typically numerics like measurement ranges or planning estimates — were silently ignored even when they would be excellent prediction targets. Adds an opt-in field-level annotation `@AI.Recommend`. When set on a scalar element (no association / composition / unmanaged), the field is included in the entity's `_Recommendations` companion alongside auto-detected value-helped fields and Fiori Elements' soft-fill placeholder renders the prediction in the empty input. `task_type` is now chosen per target column: numeric scalars opted in via `@AI.Recommend` (`cds.Integer*`, `cds.Decimal`, `cds.Double`, `cds.UInt8`) use `regression` so RPT-1 can interpolate continuous values; everything else — including value-list FKs and string fields — uses `classification` (existing behaviour). The opt-in check is what guards FK columns from being reclassified: those carry no `@AI.Recommend`, so they remain categorical even though their CDS type is numeric. Widens the `task_type` enum on `predictRowColumns` from `{classification}` to `{classification, regression}` so the action signature accepts both. --- CHANGELOG.md | 1 + README.md | 22 ++++++++++++++++++ lib/csn-enhancements/recommendations.js | 30 +++++++++++++++++-------- srv/AICoreService.cds | 3 ++- srv/AICoreService.js | 26 ++++++++++++++++++++- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02058e..5760c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Provide a CAP `AICore` service, via which SAP AI Core artefacts can be queried, like 'resourceGroups', 'deployments' or 'configurations' with `cds.ql` (`SELECT.from(resourceGroups)` and alike). - Automatically create an AI Core deployment for SAP RPT-1 which is used for the recommended values in single tenant and multi tenant scenarios. - Automatically creates an AI Core resource group per tenant in multi tenant scenarios. In single tenant mode the 'default' resource group is used. +- New `@AI.Recommend` opt-in annotation for scalar fields without a value help (free-form numerics, free-text). Annotated fields are added to the entity's `_Recommendations` companion alongside value-helped fields. RPT-1 `task_type` is selected per column: numeric scalars opted in via `@AI.Recommend` 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). diff --git a/README.md b/README.md index 44f14df..7e310e9 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,28 @@ 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 `@AI.Recommend` to opt in: + +```cds +entity CalibrationData : cuid { + measuringRangeMin : Decimal(16, 6) @AI.Recommend; + measuringRangeMax : Decimal(16, 6) @AI.Recommend; + operatingPoint : Decimal(16, 6) @AI.Recommend; + description : String @AI.Recommend; +} +``` + +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 `_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 `@AI.Recommend` → **`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 — `@AI.Recommend` 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. diff --git a/lib/csn-enhancements/recommendations.js b/lib/csn-enhancements/recommendations.js index 2f7b617..ab63b12 100644 --- a/lib/csn-enhancements/recommendations.js +++ b/lib/csn-enhancements/recommendations.js @@ -55,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 `@AI.Recommend` 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['@AI.Recommend'] && !def.target && !def.on) { + vhFields[ele] = structuredClone(def); + delete vhFields[ele].key; } return vhFields; }, {}); diff --git a/srv/AICoreService.cds b/srv/AICoreService.cds index 143733a..4c85bf9 100644 --- a/srv/AICoreService.cds +++ b/srv/AICoreService.cds @@ -226,7 +226,8 @@ service AICore { name : String; prediction_placeholder : String; task_type : String enum { - classification = 'classification' + classification = 'classification'; + regression = 'regression'; } } }, diff --git a/srv/AICoreService.js b/srv/AICoreService.js index ad27ffd..ba97f9f 100644 --- a/srv/AICoreService.js +++ b/srv/AICoreService.js @@ -50,6 +50,30 @@ 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. Numeric scalars opted in via +// `@AI.Recommend` get `regression` so the model can interpolate continuous +// values; everything else (categorical, value-help-backed FKs, strings) gets +// `classification`. The opt-in check protects against treating a value-list +// FK column — auto-generated and inheriting the FK's numeric type — as +// regression: those carry no `@AI.Recommend`, so they remain categorical. +function pickTaskType(entity, columnName) { + const ele = entity?.elements?.[columnName]; + if (!ele) return 'classification'; + if (ele['@AI.Recommend'] && NUMERIC_CDS_TYPES.has(ele.type)) return 'regression'; + return 'classification'; +} + export default class AICore extends cds.ApplicationService { init() { this.on('fetchPredictions', this._fetchPrediction); @@ -130,7 +154,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 From 6c92b578e7925f6294d9c7ba4b41ceebd9eab3ca Mon Sep 17 00:00:00 2001 From: Soeren Leibach Date: Thu, 30 Apr 2026 15:27:44 +0200 Subject: [PATCH 5/6] fix: guard RPT-1 limits and empty-rows crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related stability bugs hit by larger schemas: 1. Empty rows. Reading a draft entity whose composition was empty passed `rows: []` to `_fetchPrediction`, whose schema-derivation reduce immediately dereferenced `rows[0][ele]` on `undefined` — taking the whole server down on a TypeError. `_fetchPrediction` now returns `{}` for empty input. The READ handler also short-circuits when the response set is empty, avoiding a needless AI Core round-trip. 2. RPT-1 inference limits. The model rejects payloads with more than 10 target columns or more than 100 columns per row, returning a noisy HTTP 422. The plugin previously sent through whatever the model had collected, so any sufficiently wide entity (think denormalised composition children with many value-helped fields) produced a 422 on every read. `_fetchPrediction` now checks both limits up front, logs a warning with a hint to opt columns out via `@UI.RecommendationState : 0`, and returns an empty result — the surrounding READ still completes, the page still renders, the user just doesn't get recommendations on that particular entity until they trim the column set. --- CHANGELOG.md | 4 +++- lib/handlers/recommendations.js | 4 ++++ srv/AICoreService.js | 26 ++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5760c7b..d214838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,4 +15,6 @@ ### 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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/lib/handlers/recommendations.js b/lib/handlers/recommendations.js index 78ea825..5ec9a0f 100644 --- a/lib/handlers/recommendations.js +++ b/lib/handlers/recommendations.js @@ -80,6 +80,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) { diff --git a/srv/AICoreService.js b/srv/AICoreService.js index ba97f9f..8f0eedd 100644 --- a/srv/AICoreService.js +++ b/srv/AICoreService.js @@ -74,6 +74,13 @@ function pickTaskType(entity, columnName) { 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); @@ -134,6 +141,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) => { From 727c90cbf5c7f8408f8167e381384a81e5d219fe Mon Sep 17 00:00:00 2001 From: Marten Schiwek Date: Thu, 30 Apr 2026 15:42:09 +0200 Subject: [PATCH 6/6] Switch to known annotation --- CHANGELOG.md | 19 ++++++++++++------- README.md | 14 +++++++------- lib/csn-enhancements/recommendations.js | 4 ++-- srv/AICoreService.js | 17 ++++++++++------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5760c7b..63f5c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,20 @@ - The format is based on [Keep a Changelog](https://keepachangelog.com/). - This project adheres to [Semantic Versioning](https://semver.org/). -## Version 1.0.0-alpha.1 - TBD +## Version 1.1.0 - Upcoming ### Added -- Out of box support for recommended values in field helps in Fiori UIs by providing an `SAP_Recommendations` navigation property in OData services which contains the recommendations. -- Provide a CAP `AICore` service, via which SAP AI Core artefacts can be queried, like 'resourceGroups', 'deployments' or 'configurations' with `cds.ql` (`SELECT.from(resourceGroups)` and alike). -- Automatically create an AI Core deployment for SAP RPT-1 which is used for the recommended values in single tenant and multi tenant scenarios. -- Automatically creates an AI Core resource group per tenant in multi tenant scenarios. In single tenant mode the 'default' resource group is used. -- New `@AI.Recommend` opt-in annotation for scalar fields without a value help (free-form numerics, free-text). Annotated fields are added to the entity's `_Recommendations` companion alongside value-helped fields. RPT-1 `task_type` is selected per column: numeric scalars opted in via `@AI.Recommend` 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}`. + +- 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 `_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. \ No newline at end of file +- 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. + +## Version 1.0.0 - 28.04.2026 + +### Added +- Out of box support for recommended values in field helps in Fiori UIs by providing an `SAP_Recommendations` navigation property in OData services which contains the recommendations. +- Provide a CAP `AICore` service, via which SAP AI Core artefacts can be queried, like 'resourceGroups', 'deployments' or 'configurations' with `cds.ql` (`SELECT.from(resourceGroups)` and alike). +- Automatically create an AI Core deployment for SAP RPT-1 which is used for the recommended values in single tenant and multi tenant scenarios. +- Automatically creates an AI Core resource group per tenant in multi tenant scenarios. In single tenant mode the 'default' resource group is used. \ No newline at end of file diff --git a/README.md b/README.md index 7e310e9..5ff2be2 100644 --- a/README.md +++ b/README.md @@ -60,25 +60,25 @@ 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 `@AI.Recommend` to opt in: +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) @AI.Recommend; - measuringRangeMax : Decimal(16, 6) @AI.Recommend; - operatingPoint : Decimal(16, 6) @AI.Recommend; - description : String @AI.Recommend; + 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 `_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 `@AI.Recommend` → **`regression`** so RPT-1 can interpolate continuous values, +- 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 — `@AI.Recommend` is only needed when there is *no* value help. Combining both is unnecessary. +> 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 diff --git a/lib/csn-enhancements/recommendations.js b/lib/csn-enhancements/recommendations.js index ab63b12..cc8867e 100644 --- a/lib/csn-enhancements/recommendations.js +++ b/lib/csn-enhancements/recommendations.js @@ -75,12 +75,12 @@ function enhanceEntity(name, model) { } return vhFields; } - // 2) Opt-in: scalar field annotated `@AI.Recommend` without a value + // 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['@AI.Recommend'] && !def.target && !def.on) { + if (def['@UI.RecommendationState'] && !def.target && !def.on) { vhFields[ele] = structuredClone(def); delete vhFields[ele].key; } diff --git a/srv/AICoreService.js b/srv/AICoreService.js index ba97f9f..916a159 100644 --- a/srv/AICoreService.js +++ b/srv/AICoreService.js @@ -61,16 +61,19 @@ const NUMERIC_CDS_TYPES = new Set([ 'cds.Double' ]); -// Pick the RPT-1 task type per target column. Numeric scalars opted in via -// `@AI.Recommend` get `regression` so the model can interpolate continuous -// values; everything else (categorical, value-help-backed FKs, strings) gets -// `classification`. The opt-in check protects against treating a value-list -// FK column — auto-generated and inheriting the FK's numeric type — as -// regression: those carry no `@AI.Recommend`, so they remain categorical. +// 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'; - if (ele['@AI.Recommend'] && NUMERIC_CDS_TYPES.has(ele.type)) return 'regression'; + 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'; }