diff --git a/CHANGELOG.md b/CHANGELOG.md index dd24e0b..4a26a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `_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 diff --git a/README.md b/README.md index 2bd5311..5ff2be2 100644 --- a/README.md +++ b/README.md @@ -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 `_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 (`_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. diff --git a/lib/csn-enhancements/recommendations.js b/lib/csn-enhancements/recommendations.js index 5912b8a..cc8867e 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; } @@ -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; }, {}); diff --git a/lib/handlers/recommendations.js b/lib/handlers/recommendations.js index 6a14728..79c690a 100644 --- a/lib/handlers/recommendations.js +++ b/lib/handlers/recommendations.js @@ -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) { 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 482314c..c995967 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,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); @@ -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) => { @@ -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