diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 737736d..11a1738 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,39 @@ jobs: - run: cd tests/bookshop && npm i - run: npm run test + local-tests: + runs-on: ubuntu-latest + needs: requires-approval + if: always() && (needs.requires-approval.result == 'success' || needs.requires-approval.result == 'skipped') + strategy: + fail-fast: false + matrix: + node-version: [20.x, 22.x] + cds-version: [latest] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: ${{ matrix.node-version }} + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install sap_rpt_oss + run: pip install git+https://github.com/SAP-samples/sap-rpt-1-oss + - name: Configure HuggingFace token + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + printf '{"cds":{"rpt":{"hfToken":"%s"}}}\n' "$HF_TOKEN" > tests/bookshop/.cdsrc-private.json + - run: npm i -g @sap/cds-dk@${{ matrix.cds-version }} + - run: npm i + - run: cd tests/bookshop && npm i + - name: Run local RPT e2e tests + run: node --test tests/local/local.test.js + integration-tests: runs-on: ubuntu-latest needs: requires-approval diff --git a/.gitignore b/.gitignore index 4104bfe..2d3e02d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,3 @@ gen/ package-lock.json .env .cdsrc-private.json -resources/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a7b43..9a47fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,14 @@ - 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 to use Regression prediction from RPT-1 +- - Local inference mode using the open-source [SAP RPT-1 OSS](https://huggingface.co/SAP/sap-rpt-1-oss) model (`AICore-local`), without requiring an SAP AI Core service binding +- HuggingFace Inference API mode (`AICore-hf`) as a lightweight alternative that requires only a HuggingFace token +- Automatic model download on first startup; checkpoint and sentence embedder are cached in `~/.cache/sap-rpt-1-oss/` +- Configure HuggingFace token via `cds.rpt.hfToken` in `.cdsrc-private.json` ### Changed - Extend `task_type` to `{classification, regression}` diff --git a/README.md b/README.md index 7a2de53..ec2452a 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,79 @@ cds bind ai-core -2 npm run test:hybrid ``` +### Local mode (without SAP AI Core) + +Instead of connecting to SAP AI Core, you can run the plugin locally using the open-source [SAP RPT-1 OSS](https://huggingface.co/SAP/sap-rpt-1-oss) model. Two backends are supported: + +| Kind | How it works | Requires | +|---|---|---| +| `AICore-local` | Downloads the model checkpoint (~65 MB) on first startup and runs inference locally via a Python subprocess | Python ≥3.11, `sap_rpt_oss` package | +| `AICore-hf` | Calls the HuggingFace Inference API — no local Python needed | HuggingFace token | + +#### Setup + +**1. Accept the model licence** + +Visit [https://huggingface.co/SAP/sap-rpt-1-oss](https://huggingface.co/SAP/sap-rpt-1-oss) and click **Agree** while logged in to your HuggingFace account. + +**2. Create a HuggingFace token** + +Go to [https://huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) and create a fine-grained token with the permission: +> _Read access to contents of all public gated repos you can access_ + +**3. Configure the token** + +Add a `.cdsrc-private.json` file to your project root (it is gitignored by default): + +```json +{ + "cds": { + "rpt": { + "hfToken": "hf_..." + } + } +} +``` + +**4a. Local inference (`AICore-local`)** + +Install the Python package once: + +```bash +pip install git+https://github.com/SAP-samples/sap-rpt-1-oss +``` + +Then activate local mode in your `package.json` or `.cdsrc.json`: + +```json +{ + "cds": { + "requires": { + "AICore": "AICore-local" + } + } +} +``` + +On first startup the model checkpoint is downloaded automatically and cached in `~/.cache/SAP/sap-rpt-1-oss/`. Subsequent startups skip the download and load directly from cache. + +**4b. HuggingFace Inference API (`AICore-hf`)** + +No Python installation needed. Activate with: + +```json +{ + "cds": { + "requires": { + "AICore": "AICore-hf" + } + } +} +``` + +> [!NOTE] +> The default kind for local development (no CDS profile) is `AICore-local`. Production and hybrid profiles use `AICore-btp`. + ## Support, Feedback, Contributing This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/ai/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md). diff --git a/lib/rpt/infer.py b/lib/rpt/infer.py new file mode 100644 index 0000000..696c0d0 --- /dev/null +++ b/lib/rpt/infer.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +SAP RPT-1 OSS local inference server. + +Reads JSON requests from stdin (one per line), runs predictions using the +locally cached model checkpoint, and writes JSON responses to stdout. + +Protocol: + stdin line: {"id": , "data": { ...same payload as predictRowColumns... }} + stdout line: {"id": , "result": { "predictions": [...] }} + {"id": , "error": ""} + +On startup, sends {"id": 0, "result": "ready"} once the model is loaded. +""" +import json +import sys +import os +import warnings +import traceback +import pandas as pd +from pathlib import Path + +# Silence noisy sap_rpt_oss dtype warnings — we normalise dtypes ourselves below +warnings.filterwarnings('ignore') + +# Redirect any stray prints that sap_rpt_oss writes directly to stdout so they +# don't corrupt the JSON stdout protocol +_real_stdout = sys.stdout +sys.stdout = sys.stderr + +# ─── Model loading ──────────────────────────────────────────────────────────── + +def load_model(model_path: str): + """Load the SAP RPT-1 OSS classifier using pre-downloaded cache.""" + try: + from sap_rpt_oss import SAP_RPT_OSS_Classifier + except ImportError: + _fatal( + "sap_rpt_oss package not found.\n" + "Install it with: pip install git+https://github.com/SAP-samples/sap-rpt-1-oss" + ) + + # All model files are pre-downloaded by Node.js into HF_HOME. + # Tell huggingface_hub to use only the local cache — no network calls. + os.environ.setdefault('HF_HUB_OFFLINE', '1') + + print(f"Loading model (cache: {os.environ.get('HF_HOME', 'default')}) …", file=sys.stderr, flush=True) + + clf = SAP_RPT_OSS_Classifier( + bagging=1, + max_context_size=512, + ) + return clf + +# ─── Inference ──────────────────────────────────────────────────────────────── + +def _coerce_dtypes(df, data_schema: dict | None): + """ + Coerce DataFrame columns to proper dtypes so sap_rpt_oss doesn't warn. + Uses data_schema hints when available, otherwise infers from values. + """ + for col in df.columns: + hint = (data_schema or {}).get(col, {}).get('dtype', '') + if hint in ('numeric', 'integer', 'float'): + df[col] = pd.to_numeric(df[col], errors='coerce') + elif hint in ('bool',): + df[col] = df[col].astype(bool) + elif hint in ('date', 'datetime'): + df[col] = pd.to_datetime(df[col], errors='coerce') + else: + # Try int, then float, fall back to string + try: + converted = pd.to_numeric(df[col], errors='raise', downcast='integer') + df[col] = converted + except (ValueError, TypeError): + try: + converted = pd.to_numeric(df[col], errors='raise') + df[col] = converted + except (ValueError, TypeError): + df[col] = df[col].astype(str).replace('None', pd.NA).replace('nan', pd.NA) + return df + + +def predict(clf, data: dict) -> dict: + """ + Run predictions for one request. + + data keys mirror predictRowColumns payload: + rows – list of row dicts + prediction_config – { target_columns: [{name, prediction_placeholder, task_type}] } + index_column – name of the ID column + data_schema – optional { col: {dtype} } map + """ + rows = data["rows"] + prediction_config = data["prediction_config"] + index_column = data["index_column"] + data_schema = data.get("data_schema") + target_cols = [tc["name"] for tc in prediction_config["target_columns"]] + placeholder = prediction_config["target_columns"][0].get("prediction_placeholder", "[PREDICT]") + + df = _coerce_dtypes(pd.DataFrame(rows), data_schema) + + predictions = [] + for _, row in df.iterrows(): + row_id = row[index_column] + new_pred = {index_column: row_id} + needs_prediction = any( + str(row.get(col)) == placeholder or row.get(col) is None + for col in target_cols + ) + + if not needs_prediction: + continue + + # Only predict columns that actually need it for this row + for col in target_cols: + if str(row.get(col)) != placeholder and row.get(col) is not None: + continue + + train_rows = [ + r for r in rows + if r.get(col) is not None and str(r.get(col)) != placeholder + ] + if not train_rows: + new_pred[col] = [{"prediction": None}] + continue + + X_train = _coerce_dtypes(pd.DataFrame(train_rows).drop(columns=[col], errors="ignore"), data_schema) + y_train = pd.DataFrame(train_rows)[col] + test_row = _coerce_dtypes(df[df[index_column] == row_id].drop(columns=[col], errors="ignore").copy(), data_schema) + + try: + clf.fit(X_train, y_train) + probas = clf.predict_proba(test_row) + classes = clf.classes_ + ranked = sorted(zip(classes, probas[0]), key=lambda x: x[1], reverse=True)[:3] + new_pred[col] = [{"prediction": str(c), "score": float(p)} for c, p in ranked] + except Exception as exc: + new_pred[col] = [{"prediction": None, "error": str(exc)}] + + predictions.append(new_pred) + + return {"predictions": predictions} + +# ─── I/O loop ───────────────────────────────────────────────────────────────── + +def _respond(msg: dict): + _real_stdout.write(json.dumps(msg) + "\n") + _real_stdout.flush() + +def _fatal(msg: str): + print(f"FATAL: {msg}", file=sys.stderr, flush=True) + sys.exit(1) + +def main(): + model_path = ( + sys.argv[1] + if len(sys.argv) > 1 + else os.environ.get("RPT_MODEL_PATH", "") + ) + if not model_path or not Path(model_path).exists(): + _fatal(f"Model checkpoint not found at '{model_path}'. Pass path as first argument.") + + clf = load_model(model_path) + + _respond({"id": 0, "result": "ready"}) + print("Ready — waiting for requests.", flush=True) + + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError as exc: + print(f"Bad JSON: {exc}", file=sys.stderr, flush=True) + continue + + req_id = req.get("id") + try: + result = predict(clf, req["data"]) + _respond({"id": req_id, "result": result}) + except Exception: + err = traceback.format_exc() + print(err, file=sys.stderr, flush=True) + _respond({"id": req_id, "error": err.splitlines()[-1]}) + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json index afef7fe..af9a1f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cap-js/ai", - "version": "1.0.1", + "version": "1.1.0", "description": "CAP cds-plugin for AI capabilities", "repository": "cap-js/ai", "type": "module", @@ -10,7 +10,7 @@ "main": "cds-plugin.js", "scripts": { "lint": "npx -y eslint@10 .", - "test": "node --test tests/*.test.js", + "test": "CDS_ENV=test node --test tests/*.test.js", "test:hybrid": "cds bind --exec -- node --test tests/*.test.js tests/integration/*.test.js", "format": "npx -y prettier@3 . --write && format-cds -f", "format:check": "npx -y prettier@3 --check . && format-cds --check" @@ -23,7 +23,7 @@ "devDependencies": { "@cap-js/cds-test": "^1", "@cap-js/cds-types": "^0.16.0", - "@cap-js/sqlite": "*" + "@cap-js/sqlite": ">=2" }, "peerDependencies": { "@sap/cds": ">=9" @@ -40,6 +40,9 @@ "[hybrid]": { "AICore": "AICore-btp" }, + "[test]": { + "AICore": "AICore-mocked" + }, "kinds": { "AICore-mocked": { "model": "@cap-js/ai/srv/MockAICoreService" @@ -50,6 +53,13 @@ "vcap": { "label": "aicore" } + }, + "AICore-local": { + "model": "@cap-js/ai/srv/LocalRPTService", + "local": true + }, + "AICore-hf": { + "model": "@cap-js/ai/srv/LocalRPTService" } } } diff --git a/srv/LocalRPTService.cds b/srv/LocalRPTService.cds new file mode 100644 index 0000000..5c7d873 --- /dev/null +++ b/srv/LocalRPTService.cds @@ -0,0 +1,3 @@ +using {AICore} from './AICoreService'; + +annotate AICore with @impl: './LocalRPTService.js'; diff --git a/srv/LocalRPTService.js b/srv/LocalRPTService.js new file mode 100644 index 0000000..5dda2cd --- /dev/null +++ b/srv/LocalRPTService.js @@ -0,0 +1,380 @@ +import cds from '@sap/cds'; +import { createWriteStream, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { join, dirname } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +import AICoreService from './AICoreService.js'; + +const LOG = cds.log('@cap-js/ai'); + +const MODEL_ID = 'SAP/sap-rpt-1-oss'; +const MODEL_FILE = '2025-11-04_sap-rpt-one-oss.pt'; +const MODEL_URL = `https://huggingface.co/${MODEL_ID}/resolve/main/${MODEL_FILE}`; +const HF_INFERENCE_URL = `https://api-inference.huggingface.co/models/${MODEL_ID}`; +const INFER_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '../lib/rpt/infer.py'); + +// Sentence embedder downloaded by sap_rpt_oss at classifier init time +const EMBEDDER_ID = 'sentence-transformers/all-MiniLM-L6-v2'; +const EMBEDDER_FILES = [ + 'config.json', + 'tokenizer.json', + 'tokenizer_config.json', + 'special_tokens_map.json', + 'vocab.txt', + 'model.safetensors', + '1_Pooling/config.json' +]; + +// ─── Cache helpers ──────────────────────────────────────────────────────────── + +function _cacheDir() { + return ( + cds.env.requires?.AICore?.cacheDir ?? + join(process.env.HOME ?? process.cwd(), '.cache', 'sap-rpt-1-oss') + ); +} + +function _modelPath() { + return join(_cacheDir(), MODEL_FILE); +} + +/** HuggingFace hub cache root — transformers/huggingface_hub look here. */ +function _hfHome() { + return join(_cacheDir(), 'hf'); +} + +/** + * Returns the directory where huggingface_hub caches a given repo, following + * the standard layout: /hub/models----/snapshots// + */ +function _hfRepoCache(repoId, ref = 'main') { + const repoDir = 'models--' + repoId.replace('/', '--'); + return join(_hfHome(), 'hub', repoDir, 'snapshots', ref); +} + +function _hfToken() { + return cds.env.rpt?.hfToken ?? ''; +} + +// ─── Download helpers ───────────────────────────────────────────────────────── + +/** Download a single URL to dest with a live progress bar on stderr. */ +async function _downloadFile(url, dest, label, headers = {}) { + mkdirSync(dirname(dest), { recursive: true }); + + const res = await fetch(url, { headers }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error( + `Access denied when downloading ${label} (HTTP ${res.status}).\n\n` + + ` SAP/sap-rpt-1-oss is a gated model — you need a HuggingFace account and must\n` + + ` accept the model licence at https://huggingface.co/${MODEL_ID}\n\n` + + ` Then provide your HF token via .cdsrc-private.json (gitignored):\n` + + ` { "cds": { "rpt": { "hfToken": "hf_..." } } }\n\n` + + ` Generate a token at https://huggingface.co/settings/tokens\n` + + ` Required permission: "Read access to contents of all public gated repos you can access"` + ); + } + throw new Error(`Download failed for ${label}: ${res.status} ${res.statusText}`); + } + + const total = parseInt(res.headers.get('content-length') ?? '0', 10); + let downloaded = 0; + const startTime = Date.now(); + const BAR_WIDTH = 30; + + function _drawBar() { + const pct = total > 0 ? downloaded / total : 0; + const filled = Math.round(BAR_WIDTH * pct); + const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled); + const mb = (n) => (n / 1048576).toFixed(1) + ' MB'; + const elapsed = (Date.now() - startTime) / 1000; + const speed = elapsed > 0 ? downloaded / elapsed : 0; + const eta = total > 0 && speed > 0 ? Math.ceil((total - downloaded) / speed) + 's' : '…'; + const pctStr = total > 0 ? (pct * 100).toFixed(1).padStart(5) + '%' : ' … '; + process.stderr.write( + `\r ${bar} ${pctStr} ${mb(downloaded)}${total > 0 ? ' / ' + mb(total) : ''} ${speed > 0 ? mb(speed) + '/s' : ''} ETA ${eta} ` + ); + } + + const { Transform } = await import('node:stream'); + const tracker = new Transform({ + transform(chunk, _enc, cb) { + downloaded += chunk.length; + _drawBar(); + cb(null, chunk); + } + }); + + process.stderr.write(`\n Downloading ${label}\n`); + _drawBar(); + await pipeline(res.body, tracker, createWriteStream(dest)); + process.stderr.write('\n'); +} + +const MODEL_MIN_SIZE = 50 * 1024 * 1024; // 50 MB — guards against partial downloads + +/** Ensure the RPT-1 checkpoint is on disk. */ +async function ensureModel() { + const dest = _modelPath(); + if (existsSync(dest) && (await stat(dest)).size >= MODEL_MIN_SIZE) return dest; + LOG.info(`[Local RPT] downloading model checkpoint from ${MODEL_URL}`); + const token = _hfToken(); + await _downloadFile( + MODEL_URL, + dest, + `${MODEL_FILE} (${MODEL_ID})`, + token ? { Authorization: `Bearer ${token}` } : {} + ); + process.stderr.write(' Download complete.\n\n'); + return dest; +} + +/** + * Ensure the sentence embedder is in the HF hub cache layout so + * huggingface_hub finds it without hitting the network. + * + * Layout: /hub/models--sentence-transformers--all-MiniLM-L6-v2/ + * snapshots/main/ + */ +async function ensureEmbedder() { + const snapshotDir = _hfRepoCache(EMBEDDER_ID); + const token = _hfToken(); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const base = `https://huggingface.co/${EMBEDDER_ID}/resolve/main`; + + const missing = await Promise.all( + EMBEDDER_FILES.map(async (file) => { + const dest = join(snapshotDir, file); + if (existsSync(dest) && (await stat(dest)).size > 0) return null; + return file; + }) + ).then((files) => files.filter(Boolean)); + + if (missing.length) { + LOG.info(`[Local RPT] downloading sentence embedder (${EMBEDDER_ID})`); + await Promise.all( + missing.map((file) => + _downloadFile(`${base}/${file}`, join(snapshotDir, file), `${EMBEDDER_ID}/${file}`, headers) + ) + ); + } + + // Write the refs/main pointer that huggingface_hub uses to resolve the snapshot + const refPath = join( + _hfHome(), + 'hub', + 'models--' + EMBEDDER_ID.replace('/', '--'), + 'refs', + 'main' + ); + if (!existsSync(refPath)) { + mkdirSync(dirname(refPath), { recursive: true }); + writeFileSync(refPath, 'main'); + } + + if (missing.length) process.stderr.write(' Embedder ready.\n\n'); +} + +// ─── HuggingFace Inference API mode ────────────────────────────────────────── + +export class HFInferenceRPTService extends AICoreService { + async _getToken() { + const token = _hfToken(); + if (!token) + throw new cds.error( + 'Missing HuggingFace token. Configure cds.rpt.hfToken in .cdsrc-private.json.' + ); + return token; + } + + async _predictRowColumns(req) { + const token = await this._getToken(); + const { prediction_config, index_column, rows, data_schema } = req.data; + + LOG.debug(`[HF Inference] SAP/sap-rpt-1-oss — ${rows.length} row(s)`); + + const res = await fetch(HF_INFERENCE_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + inputs: { + data: rows, + target_columns: prediction_config.target_columns, + index_column, + ...(data_schema && { data_schema }) + } + }) + }); + + if (!res.ok) { + const ct = res.headers.get('content-type') ?? ''; + const detail = ct.includes('json') ? JSON.stringify(await res.json()) : await res.text(); + LOG.error(`[HF Inference] ${res.status}: ${detail}`); + return {}; + } + + const result = await res.json(); + if (result.error) { + LOG.error(`[HF Inference] model error: ${result.error}`); + return {}; + } + return result?.predictions !== undefined ? result : { predictions: result }; + } +} + +// ─── Local Python subprocess mode ──────────────────────────────────────────── + +export class LocalSubprocessRPTService extends AICoreService { + /** @type {import('node:child_process').ChildProcess | null} */ + _proc = null; + /** @type {Map} */ + _pending = new Map(); + _nextId = 1; + /** Promise that resolves once the subprocess is ready (set in _boot). */ + _ready = null; + + init() { + // Defer boot until after CDS has finished loading .env and all config, + // so HF_TOKEN and other env vars are reliably available. + cds.once('served', () => { + this._ready = this._boot(); + this._ready.catch((err) => { + if (!err.message.includes('sap_rpt_oss not installed')) + LOG.error('[Local RPT] startup failed:', err); + }); + }); + return super.init(); + } + + async _boot() { + // 1. Download model checkpoint + sentence embedder if needed + const modelPath = await ensureModel(); + await ensureEmbedder(); + + // 2. Spawn Python inference server + const python = cds.env.requires?.AICore?.python ?? 'python3'; + LOG.info('[Local RPT] starting Python inference process…'); + + const token = _hfToken(); + this._proc = spawn(python, [INFER_SCRIPT, modelPath], { + env: { + ...process.env, + HF_TOKEN: token, + HUGGING_FACE_HUB_TOKEN: token, // legacy env var checked by some HF libs + HF_HOME: _hfHome(), // point Python at our pre-downloaded cache + RPT_MODEL_PATH: modelPath + }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Stderr from Python — buffer lines so we can detect known errors + this._proc.stderr.setEncoding('utf8'); + const stderrLines = []; + this._proc.stderr.on('data', (chunk) => { + for (const line of chunk.split('\n').filter(Boolean)) { + stderrLines.push(line); + LOG.info(`[Local RPT py] ${line}`); + } + }); + + // Each stdout line is a JSON response to a pending request + const rl = createInterface({ input: this._proc.stdout }); + rl.on('line', (line) => { + let msg; + try { + msg = JSON.parse(line); + } catch { + LOG.warn(`[Local RPT] unexpected stdout: ${line}`); + return; + } + const h = this._pending.get(msg.id); + if (!h) return; + this._pending.delete(msg.id); + msg.error ? h.reject(new Error(msg.error)) : h.resolve(msg.result); + }); + + this._proc.on('exit', (code) => { + const missingPackage = stderrLines.some((l) => l.includes('sap_rpt_oss package not found')); + if (missingPackage) { + const border = '─'.repeat(60); + process.stderr.write( + `\n ┌${border}┐\n` + + ` │ sap_rpt_oss Python package is not installed. │\n` + + ` │ │\n` + + ` │ Install it once with: │\n` + + ` │ pip install git+https://github.com/SAP-samples/ │\n` + + ` │ sap-rpt-1-oss │\n` + + ` │ │\n` + + ` │ Requires: Python ≥3.11, torch, transformers │\n` + + ` └${border}┘\n\n` + ); + } + if (this._pending.size > 0) { + const err = missingPackage + ? new Error( + 'sap_rpt_oss not installed — run: pip install git+https://github.com/SAP-samples/sap-rpt-1-oss' + ) + : new Error(`Python inference process exited (code ${code})`); + for (const [, h] of this._pending) h.reject(err); + this._pending.clear(); + } + this._proc = null; + this._ready = null; + }); + + // Python sends {"id":0,"result":"ready"} once the model is loaded + await new Promise((resolve, reject) => this._pending.set(0, { resolve, reject })); + LOG.info('[Local RPT] model ready'); + } + + async _predictRowColumns(req) { + if (!this._ready) this._ready = this._boot(); + await this._ready; + + const id = this._nextId++; + LOG.debug(`[Local RPT] request #${id} — ${req.data.rows?.length ?? '?'} row(s)`); + + return new Promise((resolve, reject) => { + if (!this._proc) return reject(new Error('Python inference process is not running')); + this._pending.set(id, { resolve, reject }); + this._proc.stdin.write(JSON.stringify({ id, data: req.data }) + '\n'); + }); + } +} + +// ─── Default export ─────────────────────────────────────────────────────────── + +/** + * LocalRPTService — used by both `AICore-local` and `AICore-hf` kinds. + * + * Set `cds.requires.AICore.local: true` to run the model entirely on this + * machine (requires Python ≥3.11 + `sap_rpt_oss` installed). + * Omit `local` (or set it to false) to call the HuggingFace Inference API. + * + * On first use with `local: true` the model checkpoint (~65 MB) is downloaded + * from HuggingFace automatically during CDS startup and cached in + * `~/.cache/SAP/sap-rpt-1-oss/` (override with `cacheDir`). + */ +export default class LocalRPTService extends AICoreService { + init() { + const cfg = cds.env.requires?.AICore ?? {}; + if (cfg.local === true) { + const backend = new LocalSubprocessRPTService(); + backend.init(); // registers cds.once('served') boot + this._predictRowColumns = backend._predictRowColumns.bind(backend); + this._getToken = () => Promise.resolve(_hfToken()); + // _ready is set by backend after 'served' fires; forward it lazily + Object.defineProperty(this, '_ready', { get: () => backend._ready }); + } else { + const backend = new HFInferenceRPTService(); + this._predictRowColumns = backend._predictRowColumns.bind(backend); + this._getToken = backend._getToken.bind(backend); + } + return super.init(); + } +} diff --git a/tests/bookshop/app/ai-bookshop/ui5-deploy.yaml b/tests/bookshop/app/ai-bookshop/ui5-deploy.yaml index 38f3b1c..59eab7b 100644 --- a/tests/bookshop/app/ai-bookshop/ui5-deploy.yaml +++ b/tests/bookshop/app/ai-bookshop/ui5-deploy.yaml @@ -1,21 +1,21 @@ # yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json specVersion: '4.0' metadata: - name: ai-bookshop + name: ai-bookshop type: application resources: - configuration: - propertiesFileSourceEncoding: UTF-8 + configuration: + propertiesFileSourceEncoding: UTF-8 builder: - resources: - excludes: - - '/test/**' - - '/localService/**' - customTasks: - - name: ui5-task-zipper - afterTask: generateCachebusterInfo - configuration: - archiveName: ai-bookshop - relativePaths: true - additionalFiles: - - xs-app.json + resources: + excludes: + - '/test/**' + - '/localService/**' + customTasks: + - name: ui5-task-zipper + afterTask: generateCachebusterInfo + configuration: + archiveName: ai-bookshop + relativePaths: true + additionalFiles: + - xs-app.json diff --git a/tests/bookshop/app/ai-bookshop/ui5.yaml b/tests/bookshop/app/ai-bookshop/ui5.yaml index e58b92a..3acaa23 100644 --- a/tests/bookshop/app/ai-bookshop/ui5.yaml +++ b/tests/bookshop/app/ai-bookshop/ui5.yaml @@ -8,8 +8,8 @@ resources: builder: resources: excludes: - - "/test/**" - - "/localService/**" + - '/test/**' + - '/localService/**' customTasks: - name: ui5-task-zipper afterTask: generateVersionInfo diff --git a/tests/bookshop/mta.yaml b/tests/bookshop/mta.yaml index 4eeaf86..447e300 100644 --- a/tests/bookshop/mta.yaml +++ b/tests/bookshop/mta.yaml @@ -1,4 +1,4 @@ -_schema-version: "3.1" +_schema-version: '3.1' ID: intelligent-cap-app description: A simple CAP project. version: 1.0.0 @@ -94,8 +94,7 @@ modules: commands: - npm install - npm run build:cf - supported-platforms: - [] + supported-platforms: [] resources: - name: intelligent-cap-app-db type: com.sap.xs.hdi-container diff --git a/tests/local/local.test.js b/tests/local/local.test.js new file mode 100644 index 0000000..f72951b --- /dev/null +++ b/tests/local/local.test.js @@ -0,0 +1,118 @@ +import path from 'path'; +import { describe, test, before } from 'node:test'; +import assert from 'node:assert'; +import cdsTest from '@cap-js/cds-test'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let { GET, POST, PATCH } = cdsTest(path.join(__dirname, './bookshop')); + +describe('Local RPT recommendations (AICore-local e2e)', () => { + before(async () => { + // Wait for the Python subprocess to boot and the model to be ready. + // LocalSubprocessRPTService exposes _ready on the AICore service instance. + const aiCore = await (await import('@sap/cds')).default.connect.to('AICore'); + if (aiCore._ready) await aiCore._ready; + }); + + test('recommendations are returned in draft mode', async () => { + const { + data: { ID } + } = await POST('/odata/v4/catalog/Books', { + ID: Math.round(Math.random() * 10000) + }); + const { status, data } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(status, 200); + assert.ok(data.SAP_Recommendations); + assert.ok(data.SAP_Recommendations.author_ID.length); + }); + + test('recommendations contain a default suggestion', async () => { + const { + data: { ID } + } = await POST('/odata/v4/catalog/Books', { + ID: Math.round(Math.random() * 10000) + }); + const { status, data } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(status, 200); + for (const field of Object.keys(data.SAP_Recommendations)) { + assert.strictEqual(data.SAP_Recommendations[field][0].RecommendedFieldIsSuggestion, true); + } + }); + + test('description populated via @Common.Text', async () => { + const { + data: { ID } + } = await POST('/odata/v4/catalog/Books', { + ID: Math.round(Math.random() * 10000) + }); + const { data } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + const rec = data.SAP_Recommendations['author_ID'][0]; + assert.ok(rec.RecommendedFieldDescription, 'expected description from @Common.Text'); + }); + + test('@UI.RecommendationState: 0 disables field', async () => { + const { + data: { ID } + } = await POST('/odata/v4/catalog/Books', { + ID: Math.round(Math.random() * 10000) + }); + const { data } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(!!data.SAP_Recommendations['authorWORecommendations_ID'], false); + }); + + test('dynamic @UI.RecommendationState expression enables/disables field', async () => { + const { + data: { ID } + } = await POST('/odata/v4/catalog/Books', { + ID: Math.round(Math.random() * 10000), + genre_ID: 13 + }); + const { data: off } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(!!off.SAP_Recommendations['authorWDynamicRecommendations_ID'], false); + + await PATCH(`/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)`, { genre_ID: 10 }); + const { data: on } = await GET( + `/odata/v4/catalog/Books(ID=${ID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(!!on.SAP_Recommendations['authorWDynamicRecommendations_ID'], true); + }); + + test('entity with non-ID key returns recommendations', async () => { + const { + data: { notID } + } = await POST('/odata/v4/catalog/BooksWithCustomKey', { + notID: Math.round(Math.random() * 10000) + }); + const { status, data } = await GET( + `/odata/v4/catalog/BooksWithCustomKey(notID=${notID},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(status, 200); + assert.ok(data.SAP_Recommendations.currency_code.length); + }); + + test('entity with composed keys returns recommendations', async () => { + const { + data: { key1, key2 } + } = await POST('/odata/v4/catalog/BooksWithComposedKey', { + key1: Math.round(Math.random() * 10000), + key2: Math.round(Math.random() * 10000) + }); + const { status, data } = await GET( + `/odata/v4/catalog/BooksWithComposedKey(key1=${key1},key2=${key2},IsActiveEntity=false)?$expand=SAP_Recommendations` + ); + assert.strictEqual(status, 200); + assert.ok(data.SAP_Recommendations.currency_code.length); + }); +});