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
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ gen/
package-lock.json
.env
.cdsrc-private.json
resources/
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,79 @@ cds bind ai-core -2 <your-ai-core-service-instance>
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).
Expand Down
189 changes: 189 additions & 0 deletions lib/rpt/infer.py
Original file line number Diff line number Diff line change
@@ -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": <int>, "data": { ...same payload as predictRowColumns... }}
stdout line: {"id": <int>, "result": { "predictions": [...] }}
{"id": <int>, "error": "<message>"}

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):
Comment thread
KoblerS marked this conversation as resolved.
"""
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]})
Comment thread
KoblerS marked this conversation as resolved.

if __name__ == "__main__":
main()
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -40,6 +40,9 @@
"[hybrid]": {
"AICore": "AICore-btp"
},
"[test]": {
"AICore": "AICore-mocked"
},
"kinds": {
"AICore-mocked": {
"model": "@cap-js/ai/srv/MockAICoreService"
Expand All @@ -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"
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions srv/LocalRPTService.cds
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using {AICore} from './AICoreService';

annotate AICore with @impl: './LocalRPTService.js';
Loading
Loading