jev-aicore 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jev_aicore-1.0.0/PKG-INFO +5 -0
- jev_aicore-1.0.0/README.md +446 -0
- jev_aicore-1.0.0/jev_aicore/__init__.py +73 -0
- jev_aicore-1.0.0/jev_aicore.egg-info/PKG-INFO +5 -0
- jev_aicore-1.0.0/jev_aicore.egg-info/SOURCES.txt +9 -0
- jev_aicore-1.0.0/jev_aicore.egg-info/dependency_links.txt +1 -0
- jev_aicore-1.0.0/jev_aicore.egg-info/top_level.txt +1 -0
- jev_aicore-1.0.0/pyproject.toml +13 -0
- jev_aicore-1.0.0/setup.cfg +4 -0
- jev_aicore-1.0.0/tests/test_server.py +334 -0
- jev_aicore-1.0.0/tests/testing.py +39 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# jev-aicore-proxy
|
|
2
|
+
|
|
3
|
+
A lightweight FastAPI proxy that exposes TypeSafe Jev 1.13 (via OpenRouter) through an SAP AI Core managed inference endpoint.
|
|
4
|
+
|
|
5
|
+
This is a PoC. The goal is to verify that structured Jev System One inference can be reached through SAP AI Core before committing to a separate Jev subscription.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
sequenceDiagram
|
|
13
|
+
participant Client as Application / Test Client
|
|
14
|
+
participant AIC as SAP AI Core Endpoint
|
|
15
|
+
participant Proxy as jev-aicore-proxy (FastAPI)
|
|
16
|
+
participant OR as OpenRouter
|
|
17
|
+
participant Jev as TypeSafe Jev 1.13
|
|
18
|
+
|
|
19
|
+
Client->>AIC: POST /v1/systemone<br/>(SAP AI Core bearer token)
|
|
20
|
+
AIC->>Proxy: forward request
|
|
21
|
+
Proxy->>OR: TypeSafe SDK system_one()<br/>(OPENROUTER_API_KEY — injected secret)
|
|
22
|
+
OR->>Jev: System One inference
|
|
23
|
+
Jev-->>OR: Structured answers
|
|
24
|
+
OR-->>Proxy: SystemOneResponse
|
|
25
|
+
Proxy-->>AIC: JSON { model, usage, answers }
|
|
26
|
+
AIC-->>Client: same JSON
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Authentication boundaries:**
|
|
30
|
+
- The client authenticates to SAP AI Core with a bearer token obtained from your BTP XSUAA instance.
|
|
31
|
+
- The proxy authenticates to OpenRouter with the `OPENROUTER_API_KEY` injected as a Generic Secret by SAP AI Core at deployment time.
|
|
32
|
+
- The client never sees or needs the OpenRouter key.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Project structure
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
jev-aicore-proxy/
|
|
40
|
+
├── server.py FastAPI application
|
|
41
|
+
├── requirements.txt Runtime dependencies (pinned)
|
|
42
|
+
├── Dockerfile Container image definition
|
|
43
|
+
├── .dockerignore
|
|
44
|
+
├── .gitignore
|
|
45
|
+
├── .env.example Placeholder for local development
|
|
46
|
+
├── pytest.ini
|
|
47
|
+
├── tests/
|
|
48
|
+
│ └── test_server.py Unit tests (mocked) + integration test
|
|
49
|
+
└── deployment/
|
|
50
|
+
└── jev-serving.yaml SAP AI Core ServingTemplate
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Prerequisites
|
|
56
|
+
|
|
57
|
+
| Requirement | Notes |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Python 3.12 | Tested with 3.12.14 |
|
|
60
|
+
| typesafe-sdk 0.7.5 | Installed from PyPI |
|
|
61
|
+
| OpenRouter API key | `OPENROUTER_API_KEY` |
|
|
62
|
+
| Docker (optional) | For container build |
|
|
63
|
+
| SAP AI Core access | For actual deployment |
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Local setup
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Clone / open the project
|
|
71
|
+
cd jev-aicore-proxy
|
|
72
|
+
|
|
73
|
+
# Install all dependencies (runtime + dev)
|
|
74
|
+
pip install -r requirements.txt pytest pytest-mock pytest-asyncio httpx
|
|
75
|
+
|
|
76
|
+
# Copy the example env file and add your key
|
|
77
|
+
cp .env.example .env
|
|
78
|
+
# Edit .env and set OPENROUTER_API_KEY=sk-or-v1-...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Environment configuration
|
|
84
|
+
|
|
85
|
+
| Variable | Required | Description |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for Jev model access |
|
|
88
|
+
|
|
89
|
+
The application refuses to start if the key is missing.
|
|
90
|
+
|
|
91
|
+
**Never commit `.env`** — it is listed in `.gitignore`.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## API endpoints
|
|
96
|
+
|
|
97
|
+
### `GET /v2/health`
|
|
98
|
+
|
|
99
|
+
Returns `{"status": "ok"}`. Does not call OpenRouter.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
curl http://localhost:8080/v2/health
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `POST /v1/systemone`
|
|
106
|
+
|
|
107
|
+
Runs a Jev System One structured decision request.
|
|
108
|
+
|
|
109
|
+
**Request body:**
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"state": {
|
|
114
|
+
"customer_tier": "enterprise",
|
|
115
|
+
"ticket": "Checkout page shows blank screen after clicking Pay."
|
|
116
|
+
},
|
|
117
|
+
"questions": {
|
|
118
|
+
"is_bug": {
|
|
119
|
+
"type": "noul",
|
|
120
|
+
"instructions": "Is the customer reporting a software defect?",
|
|
121
|
+
"criteria": {
|
|
122
|
+
"true": "The product behaves unexpectedly.",
|
|
123
|
+
"false": "The customer is asking a question or requesting a feature."
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"team": {
|
|
127
|
+
"type": "choice",
|
|
128
|
+
"instructions": "Which team should own this ticket?",
|
|
129
|
+
"criteria": {
|
|
130
|
+
"payments": "Checkout, billing, or payment processing issues.",
|
|
131
|
+
"frontend": "Rendering, layout, or browser compatibility issues.",
|
|
132
|
+
"account": "Login, permissions, or profile issues."
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"urgency": {
|
|
136
|
+
"type": "score",
|
|
137
|
+
"instructions": "How urgent is this ticket?",
|
|
138
|
+
"criteria": [
|
|
139
|
+
"Can wait for the next release",
|
|
140
|
+
"Should be fixed this week",
|
|
141
|
+
"Blocking revenue right now"
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Example curl:**
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
curl -s -X POST http://localhost:8080/v1/systemone \
|
|
152
|
+
-H "Content-Type: application/json" \
|
|
153
|
+
-d @- <<'EOF'
|
|
154
|
+
{
|
|
155
|
+
"state": {"customer_tier": "enterprise", "ticket": "Blank screen after Pay click."},
|
|
156
|
+
"questions": {
|
|
157
|
+
"is_bug": {"type": "noul", "instructions": "Software defect?",
|
|
158
|
+
"criteria": {"true": "Unexpected behavior.", "false": "Feature request."}}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
EOF
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Response shape:**
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"model": "jev-1.13",
|
|
169
|
+
"usage": {"input_tokens": 42, "output_tokens": null},
|
|
170
|
+
"answers": {
|
|
171
|
+
"is_bug": {"type": "noul", "noul": 0.97},
|
|
172
|
+
"team": {"type": "choice", "choice": "frontend", "confidence": 0.91,
|
|
173
|
+
"probabilities": {"payments": 0.05, "frontend": 0.91, "account": 0.04}},
|
|
174
|
+
"urgency":{"type": "score", "score": 1.7, "confidence": 0.85,
|
|
175
|
+
"legend": {"0": "Can wait", "1": "This week", "2": "Blocking revenue"},
|
|
176
|
+
"probabilities": {"0": 0.05, "1": 0.50, "2": 0.45}}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Answer types:**
|
|
182
|
+
|
|
183
|
+
| Type | Key fields |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `noul` | `noul` (float 0–1, probability of true/yes) |
|
|
186
|
+
| `choice` | `choice` (string), `confidence`, `probabilities` |
|
|
187
|
+
| `score` | `score` (float), `confidence`, `legend`, `probabilities` |
|
|
188
|
+
|
|
189
|
+
**HTTP status codes:**
|
|
190
|
+
|
|
191
|
+
| Code | Meaning |
|
|
192
|
+
|---|---|
|
|
193
|
+
| 200 | Success |
|
|
194
|
+
| 422 | Invalid request body (missing `state` or `questions`, wrong types) |
|
|
195
|
+
| 413 | Request body exceeds 64 KB limit |
|
|
196
|
+
| 502 | Upstream OpenRouter / Jev error |
|
|
197
|
+
| 503 | Proxy not ready (SDK client unavailable) |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Running tests
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Unit tests only (no OpenRouter calls, no credits spent)
|
|
205
|
+
pytest tests/test_server.py -v -m "not integration"
|
|
206
|
+
|
|
207
|
+
# Integration test (real inference — costs credits)
|
|
208
|
+
OPENROUTER_API_KEY=sk-or-v1-... pytest tests/test_server.py -m integration -v
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Starting the server locally
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
OPENROUTER_API_KEY=sk-or-v1-... uvicorn server:app --host 0.0.0.0 --port 8080 --reload
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Or load from `.env`:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
# With python-dotenv pre-load via a helper
|
|
223
|
+
export $(grep -v '^#' .env | xargs) && uvicorn server:app --port 8080
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Building the Docker image
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
# Build
|
|
232
|
+
docker build -t jev-proxy:1.0.0 .
|
|
233
|
+
|
|
234
|
+
# Tag for GHCR
|
|
235
|
+
docker tag jev-proxy:1.0.0 ghcr.io/i780801/jev-proxy:1.0.0
|
|
236
|
+
|
|
237
|
+
# Smoke-test the container locally (host port 8081 if 8080 is in use)
|
|
238
|
+
docker run --rm -e OPENROUTER_API_KEY=sk-or-v1-... -p 8081:8080 jev-proxy:1.0.0
|
|
239
|
+
|
|
240
|
+
# Authenticate to GHCR (PAT needs write:packages scope)
|
|
241
|
+
echo "<your-ghcr-pat>" | docker login ghcr.io -u i780801 --password-stdin
|
|
242
|
+
|
|
243
|
+
# Push
|
|
244
|
+
docker push ghcr.io/i780801/jev-proxy:1.0.0
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Image pushed:** `ghcr.io/i780801/jev-proxy:1.0.0`
|
|
248
|
+
Digest: `sha256:d2e475d5f8803369f909b0a714b8d610084565de9e3d97a7fe6031114bc5db48`
|
|
249
|
+
Visibility: private (GHCR default — readable by SAP AI Core once the pull secret is registered)
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## SAP AI Core deployment
|
|
254
|
+
|
|
255
|
+
> **These steps require your explicit confirmation before execution.**
|
|
256
|
+
> No commands below have been run automatically.
|
|
257
|
+
|
|
258
|
+
### 1. Obtain your SAP AI Core bearer token
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
curl -s -X POST "$AICORE_AUTH_URL/oauth/token" \
|
|
262
|
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
263
|
+
-d "grant_type=client_credentials" \
|
|
264
|
+
-d "client_id=$AICORE_CLIENT_ID" \
|
|
265
|
+
-d "client_secret=$AICORE_CLIENT_SECRET" \
|
|
266
|
+
| jq -r .access_token
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Store the result as `AICORE_TOKEN`.
|
|
270
|
+
|
|
271
|
+
### 2. Create the OpenRouter Generic Secret
|
|
272
|
+
|
|
273
|
+
Base64-encode your key (no trailing newline):
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
echo -n "sk-or-v1-YOUR_KEY" | base64
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Then create the secret:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
curl -s -X POST "$AICORE_BASE_URL/v2/admin/secrets" \
|
|
283
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
284
|
+
-H "AI-Resource-Group: $AICORE_RG" \
|
|
285
|
+
-H "Content-Type: application/json" \
|
|
286
|
+
-d '{
|
|
287
|
+
"name": "openrouter-key",
|
|
288
|
+
"data": {
|
|
289
|
+
"OPENROUTER_API_KEY": "<base64-encoded key>"
|
|
290
|
+
}
|
|
291
|
+
}'
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### 3. Register the GHCR pull secret in SAP AI Core
|
|
295
|
+
|
|
296
|
+
The image is at `ghcr.io/i780801/jev-proxy:1.0.0` (private). SAP AI Core needs a pull secret to fetch it.
|
|
297
|
+
|
|
298
|
+
You need a GitHub PAT with **`read:packages`** scope (a separate, narrower token from the push token).
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
curl -s -X POST "$AICORE_BASE_URL/v2/admin/dockerRegistrySecrets" \
|
|
302
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
303
|
+
-H "Content-Type: application/json" \
|
|
304
|
+
-d '{
|
|
305
|
+
"name": "ghcr-secret",
|
|
306
|
+
"data": {
|
|
307
|
+
"ghcr.io": {
|
|
308
|
+
"username": "i780801",
|
|
309
|
+
"password": "<GitHub PAT with read:packages scope>"
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}'
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### 4. Register a Git repository and sync the ServingTemplate
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
# Register your repo
|
|
319
|
+
curl -s -X POST "$AICORE_BASE_URL/v2/admin/repositories" \
|
|
320
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
321
|
+
-H "Content-Type: application/json" \
|
|
322
|
+
-d '{
|
|
323
|
+
"name": "jev-aicore-proxy",
|
|
324
|
+
"url": "'"$GIT_REPO_URL"'",
|
|
325
|
+
"username": "<git-user>",
|
|
326
|
+
"password": "<git-token>"
|
|
327
|
+
}'
|
|
328
|
+
|
|
329
|
+
# Create an Application (GitOps sync)
|
|
330
|
+
curl -s -X POST "$AICORE_BASE_URL/v2/admin/applications" \
|
|
331
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
332
|
+
-H "Content-Type: application/json" \
|
|
333
|
+
-d '{
|
|
334
|
+
"applicationName": "jev-proxy-app",
|
|
335
|
+
"repositoryUrl": "'"$GIT_REPO_URL"'",
|
|
336
|
+
"revision": "HEAD",
|
|
337
|
+
"path": "deployment"
|
|
338
|
+
}'
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Wait for the sync; verify the scenario and executable are visible:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
curl -s "$AICORE_BASE_URL/v2/lm/scenarios" \
|
|
345
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
346
|
+
-H "AI-Resource-Group: $AICORE_RG" | jq
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### 5. Create a configuration and start the deployment
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
# Create configuration
|
|
353
|
+
CONFIG_ID=$(curl -s -X POST "$AICORE_BASE_URL/v2/lm/configurations" \
|
|
354
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
355
|
+
-H "AI-Resource-Group: $AICORE_RG" \
|
|
356
|
+
-H "Content-Type: application/json" \
|
|
357
|
+
-d '{
|
|
358
|
+
"name": "jev-proxy-config",
|
|
359
|
+
"executableId": "jev-proxy-serving",
|
|
360
|
+
"scenarioId": "jev-proxy",
|
|
361
|
+
"versionId": "1.0.0",
|
|
362
|
+
"parameterBindings": [],
|
|
363
|
+
"inputArtifactBindings": []
|
|
364
|
+
}' | jq -r .id)
|
|
365
|
+
|
|
366
|
+
echo "Config ID: $CONFIG_ID"
|
|
367
|
+
|
|
368
|
+
# Create the deployment
|
|
369
|
+
DEPLOYMENT_ID=$(curl -s -X POST "$AICORE_BASE_URL/v2/lm/deployments" \
|
|
370
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
371
|
+
-H "AI-Resource-Group: $AICORE_RG" \
|
|
372
|
+
-H "Content-Type: application/json" \
|
|
373
|
+
-d '{"configurationId": "'"$CONFIG_ID"'"}' | jq -r .id)
|
|
374
|
+
|
|
375
|
+
echo "Deployment ID: $DEPLOYMENT_ID"
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### 6. Wait for RUNNING status and get the inference URL
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
curl -s "$AICORE_BASE_URL/v2/lm/deployments/$DEPLOYMENT_ID" \
|
|
382
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
383
|
+
-H "AI-Resource-Group: $AICORE_RG" | jq '{status: .status, deploymentUrl: .deploymentUrl}'
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Wait until `status` is `RUNNING`.
|
|
387
|
+
|
|
388
|
+
### 7. Call the deployed endpoint
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
INFERENCE_URL="<deploymentUrl from step 6>"
|
|
392
|
+
|
|
393
|
+
# Health check
|
|
394
|
+
curl -s "$INFERENCE_URL/v2/health" \
|
|
395
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
396
|
+
-H "AI-Resource-Group: $AICORE_RG"
|
|
397
|
+
|
|
398
|
+
# System One inference
|
|
399
|
+
curl -s -X POST "$INFERENCE_URL/v1/systemone" \
|
|
400
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
401
|
+
-H "AI-Resource-Group: $AICORE_RG" \
|
|
402
|
+
-H "Content-Type: application/json" \
|
|
403
|
+
-d '{
|
|
404
|
+
"state": {"customer_tier": "enterprise", "ticket": "Blank screen after Pay click."},
|
|
405
|
+
"questions": {
|
|
406
|
+
"is_bug": {"type": "noul", "instructions": "Software defect?",
|
|
407
|
+
"criteria": {"true": "Unexpected behavior.", "false": "Feature request."}}
|
|
408
|
+
}
|
|
409
|
+
}'
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Known limitations and troubleshooting
|
|
415
|
+
|
|
416
|
+
| Symptom | Cause / Fix |
|
|
417
|
+
|---|---|
|
|
418
|
+
| Container exits immediately | `OPENROUTER_API_KEY` is missing — check secret injection |
|
|
419
|
+
| `POST /v1/systemone` → 502 | OpenRouter returned an error; check logs with `kubectl logs` or AI Core log API |
|
|
420
|
+
| `422 Unprocessable Entity` | Request body missing `state` or `questions`, or wrong JSON types |
|
|
421
|
+
| ServingTemplate not found | Git sync may still be pending — check Application status |
|
|
422
|
+
| `401` from SAP AI Core | Bearer token expired — re-obtain with `oauth/token` |
|
|
423
|
+
| Image pull failure | Docker registry secret not registered, or wrong name in YAML |
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
## Cost considerations
|
|
428
|
+
|
|
429
|
+
- **OpenRouter / Jev:** Each `POST /v1/systemone` call consumes Jev credits through OpenRouter. Unit tests mock the SDK and spend no credits. Only integration tests (`-m integration`) make real calls.
|
|
430
|
+
- **SAP AI Core:** Deployed containers accrue compute charges even when idle (running but receiving no traffic). Stop the deployment when not needed:
|
|
431
|
+
```bash
|
|
432
|
+
curl -s -X PATCH "$AICORE_BASE_URL/v2/lm/deployments/$DEPLOYMENT_ID" \
|
|
433
|
+
-H "Authorization: Bearer $AICORE_TOKEN" \
|
|
434
|
+
-H "AI-Resource-Group: $AICORE_RG" \
|
|
435
|
+
-H "Content-Type: application/json" \
|
|
436
|
+
-d '{"targetStatus": "STOPPED"}'
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
## PoC scope and limitations
|
|
442
|
+
|
|
443
|
+
- This is a testing PoC, not a production service.
|
|
444
|
+
- No fallback model, no authentication on the internal container, no database, no queue.
|
|
445
|
+
- The proxy is stateless. Horizontal scaling is possible but not configured here.
|
|
446
|
+
- SAP AI Core deployment status is **not yet verified end-to-end** — the deployment YAML is prepared with correct schema based on published documentation, but an actual deployment and live inference test has not been executed.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.parse
|
|
3
|
+
import urllib.request
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _load_env() -> dict:
|
|
8
|
+
env = {}
|
|
9
|
+
for p in [Path.cwd() / ".env", Path(__file__).parent.parent / ".env"]:
|
|
10
|
+
if p.exists():
|
|
11
|
+
for line in p.read_text().splitlines():
|
|
12
|
+
line = line.strip()
|
|
13
|
+
if line and not line.startswith("#") and "=" in line:
|
|
14
|
+
k, _, v = line.partition("=")
|
|
15
|
+
env.setdefault(k.strip(), v.strip().strip('"'))
|
|
16
|
+
return env
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_token(auth_url: str, client_id: str, client_secret: str) -> str:
|
|
20
|
+
data = urllib.parse.urlencode({
|
|
21
|
+
"grant_type": "client_credentials",
|
|
22
|
+
"client_id": client_id,
|
|
23
|
+
"client_secret": client_secret,
|
|
24
|
+
}).encode()
|
|
25
|
+
with urllib.request.urlopen(
|
|
26
|
+
urllib.request.Request(f"{auth_url}/oauth/token", data=data, method="POST")
|
|
27
|
+
) as r:
|
|
28
|
+
return json.loads(r.read())["access_token"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def call_jev(state: dict, questions: dict) -> dict:
|
|
32
|
+
"""
|
|
33
|
+
Call Jev via the SAP AI Core proxy.
|
|
34
|
+
|
|
35
|
+
Reads credentials from a .env file in the working directory:
|
|
36
|
+
AICORE_CLIENT_ID
|
|
37
|
+
AICORE_CLIENT_SECRET
|
|
38
|
+
AICORE_AUTH_URL
|
|
39
|
+
AICORE_BASE_URL
|
|
40
|
+
AICORE_RESOURCE_GROUP (optional, defaults to "default")
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
state: Decision context as a dict.
|
|
44
|
+
questions: Mapping of question name to question definition.
|
|
45
|
+
Each definition is a dict with keys:
|
|
46
|
+
type – "noul" | "choice" | "score"
|
|
47
|
+
instructions – plain-text question
|
|
48
|
+
criteria – dict (noul/choice) or list (score)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Raw JSON response from Jev as a dict:
|
|
52
|
+
model – model identifier string
|
|
53
|
+
usage – input_tokens, output_tokens
|
|
54
|
+
answers – dict of question name → answer object
|
|
55
|
+
"""
|
|
56
|
+
env = _load_env()
|
|
57
|
+
missing = [k for k in ("AICORE_CLIENT_ID", "AICORE_CLIENT_SECRET", "AICORE_AUTH_URL", "AICORE_BASE_URL") if k not in env]
|
|
58
|
+
if missing:
|
|
59
|
+
raise EnvironmentError(f"Missing required .env keys: {', '.join(missing)}")
|
|
60
|
+
|
|
61
|
+
token = _get_token(env["AICORE_AUTH_URL"], env["AICORE_CLIENT_ID"], env["AICORE_CLIENT_SECRET"])
|
|
62
|
+
req = urllib.request.Request(
|
|
63
|
+
f"{env['AICORE_BASE_URL']}/v2/inference/deployments/d00448831cb3408b/v1/systemone",
|
|
64
|
+
data=json.dumps({"state": state, "questions": questions}).encode(),
|
|
65
|
+
method="POST",
|
|
66
|
+
headers={
|
|
67
|
+
"Authorization": f"Bearer {token}",
|
|
68
|
+
"AI-Resource-Group": env.get("AICORE_RESOURCE_GROUP", "default"),
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
with urllib.request.urlopen(req) as r:
|
|
73
|
+
return json.loads(r.read())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jev_aicore
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jev-aicore"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Call Jev via SAP AI Core — one function, zero setup"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[tool.setuptools.packages.find]
|
|
13
|
+
include = ["jev_aicore*"]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for the FastAPI proxy. The TypeSafeClient is mocked throughout
|
|
3
|
+
so no real OpenRouter credits are spent.
|
|
4
|
+
|
|
5
|
+
Run unit tests:
|
|
6
|
+
pytest tests/test_server.py -v -m "not integration"
|
|
7
|
+
|
|
8
|
+
Run integration tests (real OpenRouter calls, costs credits):
|
|
9
|
+
OPENROUTER_API_KEY=sk-or-... pytest tests/test_server.py -m integration -v
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from unittest.mock import MagicMock, patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from fastapi.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Helpers: build mock SDK return values using the real Pydantic models
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def _make_mock_result():
|
|
25
|
+
"""Build a realistic SystemOneResponse using real SDK Pydantic types."""
|
|
26
|
+
from typesafe_sdk import ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneResponse, Usage
|
|
27
|
+
|
|
28
|
+
return SystemOneResponse(
|
|
29
|
+
model="jev-1.13",
|
|
30
|
+
usage=Usage(input_tokens=42, output_tokens=None),
|
|
31
|
+
answers={
|
|
32
|
+
"is_bug": NoulAnswer(type="noul", noul=0.97),
|
|
33
|
+
"team": ChoiceAnswer(
|
|
34
|
+
type="choice",
|
|
35
|
+
choice="frontend",
|
|
36
|
+
confidence=0.91,
|
|
37
|
+
probabilities={"payments": 0.05, "frontend": 0.91, "account": 0.04},
|
|
38
|
+
),
|
|
39
|
+
"urgency": ScoreAnswer(
|
|
40
|
+
type="score",
|
|
41
|
+
score=1.7,
|
|
42
|
+
confidence=0.85,
|
|
43
|
+
legend={0: "Can wait", 1: "This week", 2: "Blocking revenue"},
|
|
44
|
+
probabilities={0: 0.05, 1: 0.50, 2: 0.45},
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Fixtures
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
@pytest.fixture()
|
|
55
|
+
def client_with_mock_sdk():
|
|
56
|
+
"""TestClient whose lifespan installs a mock SDK client instead of a real one."""
|
|
57
|
+
mock_sdk = MagicMock()
|
|
58
|
+
mock_sdk.system_one.return_value = _make_mock_result()
|
|
59
|
+
|
|
60
|
+
# Patch _init_client so the lifespan receives the mock, not a real client.
|
|
61
|
+
with patch("server._init_client", return_value=mock_sdk):
|
|
62
|
+
import server
|
|
63
|
+
with TestClient(server.app, raise_server_exceptions=False) as c:
|
|
64
|
+
yield c, mock_sdk
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture()
|
|
68
|
+
def client_no_key():
|
|
69
|
+
"""TestClient whose lifespan raises RuntimeError (missing key)."""
|
|
70
|
+
with patch("server._init_client", side_effect=RuntimeError("OPENROUTER_API_KEY")):
|
|
71
|
+
import server
|
|
72
|
+
# TestClient will raise during lifespan; we catch that.
|
|
73
|
+
with pytest.raises(RuntimeError):
|
|
74
|
+
with TestClient(server.app, raise_server_exceptions=True):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Three-question reference payload (mirrors the working direct SDK example)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
THREE_QUESTION_PAYLOAD = {
|
|
83
|
+
"state": {
|
|
84
|
+
"customer_tier": "enterprise",
|
|
85
|
+
"ticket": "My checkout page shows a blank screen after I click Pay.",
|
|
86
|
+
},
|
|
87
|
+
"questions": {
|
|
88
|
+
"is_bug": {
|
|
89
|
+
"type": "noul",
|
|
90
|
+
"instructions": "Is the customer reporting a software defect?",
|
|
91
|
+
"criteria": {
|
|
92
|
+
"true": "The customer describes broken or unexpected product behavior.",
|
|
93
|
+
"false": "The customer is asking a question or requesting a feature.",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"team": {
|
|
97
|
+
"type": "choice",
|
|
98
|
+
"instructions": "Which team should own this ticket?",
|
|
99
|
+
"criteria": {
|
|
100
|
+
"payments": "Checkout, billing, or payment processing issues.",
|
|
101
|
+
"frontend": "Rendering, layout, or browser compatibility issues.",
|
|
102
|
+
"account": "Login, permissions, or profile issues.",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"urgency": {
|
|
106
|
+
"type": "score",
|
|
107
|
+
"instructions": "How urgent is this ticket?",
|
|
108
|
+
"criteria": [
|
|
109
|
+
"Can wait for the next release",
|
|
110
|
+
"Should be fixed this week",
|
|
111
|
+
"Blocking revenue right now",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Health endpoint
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
class TestHealth:
|
|
123
|
+
def test_returns_200(self, client_with_mock_sdk):
|
|
124
|
+
client, _ = client_with_mock_sdk
|
|
125
|
+
resp = client.get("/v2/health")
|
|
126
|
+
assert resp.status_code == 200
|
|
127
|
+
|
|
128
|
+
def test_body(self, client_with_mock_sdk):
|
|
129
|
+
client, _ = client_with_mock_sdk
|
|
130
|
+
resp = client.get("/v2/health")
|
|
131
|
+
assert resp.json() == {"status": "ok"}
|
|
132
|
+
|
|
133
|
+
def test_does_not_call_sdk(self, client_with_mock_sdk):
|
|
134
|
+
client, mock_sdk = client_with_mock_sdk
|
|
135
|
+
client.get("/v2/health")
|
|
136
|
+
mock_sdk.system_one.assert_not_called()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# /v1/systemone — happy path
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
class TestSystemOne:
|
|
144
|
+
def test_happy_path_status(self, client_with_mock_sdk):
|
|
145
|
+
client, _ = client_with_mock_sdk
|
|
146
|
+
resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
147
|
+
assert resp.status_code == 200
|
|
148
|
+
|
|
149
|
+
def test_happy_path_structure(self, client_with_mock_sdk):
|
|
150
|
+
client, _ = client_with_mock_sdk
|
|
151
|
+
body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
|
|
152
|
+
assert "answers" in body
|
|
153
|
+
assert "model" in body
|
|
154
|
+
assert "usage" in body
|
|
155
|
+
|
|
156
|
+
def test_answer_types_present(self, client_with_mock_sdk):
|
|
157
|
+
client, _ = client_with_mock_sdk
|
|
158
|
+
answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
|
|
159
|
+
assert "is_bug" in answers
|
|
160
|
+
assert "team" in answers
|
|
161
|
+
assert "urgency" in answers
|
|
162
|
+
|
|
163
|
+
def test_noul_answer_shape(self, client_with_mock_sdk):
|
|
164
|
+
client, _ = client_with_mock_sdk
|
|
165
|
+
answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
|
|
166
|
+
assert answers["is_bug"]["type"] == "noul"
|
|
167
|
+
assert "noul" in answers["is_bug"]
|
|
168
|
+
|
|
169
|
+
def test_choice_answer_shape(self, client_with_mock_sdk):
|
|
170
|
+
client, _ = client_with_mock_sdk
|
|
171
|
+
answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
|
|
172
|
+
assert answers["team"]["type"] == "choice"
|
|
173
|
+
assert answers["team"]["choice"] == "frontend"
|
|
174
|
+
assert "probabilities" in answers["team"]
|
|
175
|
+
|
|
176
|
+
def test_score_answer_shape(self, client_with_mock_sdk):
|
|
177
|
+
client, _ = client_with_mock_sdk
|
|
178
|
+
answers = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()["answers"]
|
|
179
|
+
assert answers["urgency"]["type"] == "score"
|
|
180
|
+
assert "score" in answers["urgency"]
|
|
181
|
+
assert "legend" in answers["urgency"]
|
|
182
|
+
|
|
183
|
+
def test_sdk_called_with_correct_args(self, client_with_mock_sdk):
|
|
184
|
+
client, mock_sdk = client_with_mock_sdk
|
|
185
|
+
client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
186
|
+
mock_sdk.system_one.assert_called_once_with(
|
|
187
|
+
model="jev-1.13",
|
|
188
|
+
state=THREE_QUESTION_PAYLOAD["state"],
|
|
189
|
+
questions=THREE_QUESTION_PAYLOAD["questions"],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def test_usage_returned(self, client_with_mock_sdk):
|
|
193
|
+
client, _ = client_with_mock_sdk
|
|
194
|
+
body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
|
|
195
|
+
assert body["usage"]["input_tokens"] == 42
|
|
196
|
+
|
|
197
|
+
def test_model_field_returned(self, client_with_mock_sdk):
|
|
198
|
+
client, _ = client_with_mock_sdk
|
|
199
|
+
body = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD).json()
|
|
200
|
+
assert body["model"] == "jev-1.13"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Validation errors
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
class TestValidation:
|
|
208
|
+
def test_missing_state(self, client_with_mock_sdk):
|
|
209
|
+
client, _ = client_with_mock_sdk
|
|
210
|
+
resp = client.post("/v1/systemone", json={"questions": {}})
|
|
211
|
+
assert resp.status_code == 422
|
|
212
|
+
|
|
213
|
+
def test_missing_questions(self, client_with_mock_sdk):
|
|
214
|
+
client, _ = client_with_mock_sdk
|
|
215
|
+
resp = client.post("/v1/systemone", json={"state": {}})
|
|
216
|
+
assert resp.status_code == 422
|
|
217
|
+
|
|
218
|
+
def test_empty_body(self, client_with_mock_sdk):
|
|
219
|
+
client, _ = client_with_mock_sdk
|
|
220
|
+
resp = client.post("/v1/systemone", json={})
|
|
221
|
+
assert resp.status_code == 422
|
|
222
|
+
|
|
223
|
+
def test_non_dict_state(self, client_with_mock_sdk):
|
|
224
|
+
client, _ = client_with_mock_sdk
|
|
225
|
+
resp = client.post("/v1/systemone", json={"state": "bad", "questions": {}})
|
|
226
|
+
assert resp.status_code == 422
|
|
227
|
+
|
|
228
|
+
def test_api_key_not_in_validation_error(self, client_with_mock_sdk):
|
|
229
|
+
client, _ = client_with_mock_sdk
|
|
230
|
+
resp = client.post("/v1/systemone", json={})
|
|
231
|
+
body_text = resp.text
|
|
232
|
+
assert "sk-or" not in body_text
|
|
233
|
+
assert "OPENROUTER" not in body_text
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Upstream failure handling
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
class TestUpstreamFailure:
|
|
241
|
+
def test_sdk_exception_returns_502(self, client_with_mock_sdk):
|
|
242
|
+
client, mock_sdk = client_with_mock_sdk
|
|
243
|
+
mock_sdk.system_one.side_effect = RuntimeError("upstream exploded")
|
|
244
|
+
resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
245
|
+
assert resp.status_code == 502
|
|
246
|
+
|
|
247
|
+
def test_sdk_exception_body_safe(self, client_with_mock_sdk):
|
|
248
|
+
client, mock_sdk = client_with_mock_sdk
|
|
249
|
+
mock_sdk.system_one.side_effect = RuntimeError("secret_key_abc123")
|
|
250
|
+
resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
251
|
+
assert "secret_key_abc123" not in resp.text
|
|
252
|
+
|
|
253
|
+
def test_client_unavailable_returns_503(self):
|
|
254
|
+
"""When _jev_client is None at request time, return 503."""
|
|
255
|
+
mock_sdk = MagicMock()
|
|
256
|
+
mock_sdk.system_one.return_value = _make_mock_result()
|
|
257
|
+
with patch("server._init_client", return_value=mock_sdk):
|
|
258
|
+
import server
|
|
259
|
+
with TestClient(server.app, raise_server_exceptions=False) as client:
|
|
260
|
+
# Simulate post-startup client teardown
|
|
261
|
+
import server as srv
|
|
262
|
+
srv._jev_client = None
|
|
263
|
+
resp = client.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
264
|
+
assert resp.status_code == 503
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Startup: missing API key
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
class TestStartup:
|
|
272
|
+
def test_missing_key_raises_at_startup(self):
|
|
273
|
+
"""_init_client() must raise RuntimeError when no key is set."""
|
|
274
|
+
import server
|
|
275
|
+
original = os.environ.pop("OPENROUTER_API_KEY", None)
|
|
276
|
+
try:
|
|
277
|
+
with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY"):
|
|
278
|
+
server._init_client()
|
|
279
|
+
finally:
|
|
280
|
+
if original is not None:
|
|
281
|
+
os.environ["OPENROUTER_API_KEY"] = original
|
|
282
|
+
|
|
283
|
+
def test_key_not_in_logs(self, caplog):
|
|
284
|
+
"""The real key must never appear in log output."""
|
|
285
|
+
import logging
|
|
286
|
+
import server
|
|
287
|
+
mock_sdk = MagicMock()
|
|
288
|
+
mock_sdk.system_one.return_value = _make_mock_result()
|
|
289
|
+
with patch("server._init_client", return_value=mock_sdk):
|
|
290
|
+
with caplog.at_level(logging.DEBUG):
|
|
291
|
+
with TestClient(server.app, raise_server_exceptions=False) as c:
|
|
292
|
+
c.get("/v2/health")
|
|
293
|
+
assert "sk-or" not in caplog.text
|
|
294
|
+
assert "OPENROUTER_API_KEY" not in caplog.text
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Integration test (real inference — requires OPENROUTER_API_KEY)
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
@pytest.mark.integration
|
|
302
|
+
class TestIntegration:
|
|
303
|
+
"""
|
|
304
|
+
These tests hit real OpenRouter and spend credits.
|
|
305
|
+
Only run when OPENROUTER_API_KEY is set and -m integration is passed.
|
|
306
|
+
|
|
307
|
+
OPENROUTER_API_KEY=sk-or-... pytest tests/test_server.py -m integration -v
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
def test_real_health(self):
|
|
311
|
+
if not os.environ.get("OPENROUTER_API_KEY", ""):
|
|
312
|
+
pytest.skip("OPENROUTER_API_KEY not set")
|
|
313
|
+
import server
|
|
314
|
+
with TestClient(server.app) as c:
|
|
315
|
+
resp = c.get("/v2/health")
|
|
316
|
+
assert resp.status_code == 200
|
|
317
|
+
|
|
318
|
+
def test_real_inference(self):
|
|
319
|
+
if not os.environ.get("OPENROUTER_API_KEY", ""):
|
|
320
|
+
pytest.skip("OPENROUTER_API_KEY not set")
|
|
321
|
+
import server
|
|
322
|
+
with TestClient(server.app) as c:
|
|
323
|
+
resp = c.post("/v1/systemone", json=THREE_QUESTION_PAYLOAD)
|
|
324
|
+
assert resp.status_code == 200
|
|
325
|
+
body = resp.json()
|
|
326
|
+
assert "answers" in body
|
|
327
|
+
answers = body["answers"]
|
|
328
|
+
assert "is_bug" in answers
|
|
329
|
+
assert "team" in answers
|
|
330
|
+
assert "urgency" in answers
|
|
331
|
+
# Verify answer shapes from real Jev response
|
|
332
|
+
assert answers["is_bug"]["type"] == "noul"
|
|
333
|
+
assert answers["team"]["type"] == "choice"
|
|
334
|
+
assert answers["urgency"]["type"] == "score"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from jev_aicore import call_jev
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
result = call_jev(
|
|
5
|
+
state={
|
|
6
|
+
"customer_tier": "enterprise",
|
|
7
|
+
"ticket": "My checkout page shows a blank screen after I click Pay. I have tried two browsers.",
|
|
8
|
+
},
|
|
9
|
+
questions={
|
|
10
|
+
"is_bug": {
|
|
11
|
+
"type": "noul",
|
|
12
|
+
"instructions": "Is the customer reporting a software defect?",
|
|
13
|
+
"criteria": {
|
|
14
|
+
"true": "The customer describes broken or unexpected product behavior.",
|
|
15
|
+
"false": "The customer is asking a question or requesting a feature.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"team": {
|
|
19
|
+
"type": "choice",
|
|
20
|
+
"instructions": "Which team should own this ticket?",
|
|
21
|
+
"criteria": {
|
|
22
|
+
"payments": "Checkout, billing, or payment processing issues.",
|
|
23
|
+
"frontend": "Rendering, layout, or browser compatibility issues.",
|
|
24
|
+
"account": "Login, permissions, or profile issues.",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"urgency": {
|
|
28
|
+
"type": "score",
|
|
29
|
+
"instructions": "How urgent is this ticket?",
|
|
30
|
+
"criteria": [
|
|
31
|
+
"Can wait for the next release",
|
|
32
|
+
"Should be fixed this week",
|
|
33
|
+
"Blocking revenue right now",
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
print(json.dumps(result, indent=2))
|