Joule Chat Architecture โ
Source: extracted from project README and merged with the former docs/joule-chat.md, 2026-05-25.
Architecture โ
The in-page chat assistant on tutorial, mission, and search pages. Backed by SAP AI Core's Orchestration Service via @sap-ai-sdk/orchestration, with optional retrieval-augmented grounding over per-step tutorial embeddings.
Admin runbook: ../operations/joule-chat-admin-settings.md.
flowchart LR
subgraph browser[Browser - Hugo page]
Trigger["joule-trigger button"]
Panel["joule-panel<br/>(transcript + form)"]
JouleJs["joule.js<br/>readPageContext()<br/>SSE consumer<br/>sessionStorage history"]
end
subgraph approuter["AppRouter (xs-app.json)"]
ConfigRoute["/api/ChatConfig<br/>auth: none"]
AuthRoute["/auth/user<br/>auth: xsuaa"]
ChatRoute["/chat/*<br/>auth: xsuaa"]
end
subgraph cap["tutorials-srv (CAP Node.js)"]
subgraph lifecycle["server.js lifecycle"]
Bootstrap["bootstrap event<br/>reserves POST /chat/stream<br/>(BEFORE OData /chat router)"]
Served["served event<br/>binds real chain:<br/>contextMw โ authMw โ rateLimit"]
end
Orchestrator["chat-orchestrator.js<br/>multi-turn loop (max 5 turns)"]
ContextBuilder["chat-context.js<br/>3-layer system prompt:<br/>PERSONA + page + user"]
RateLimit["chat-rate-limit.js<br/>per-user 24h, in-memory"]
subgraph entities[Data model]
ChatSettings[("ims.ChatSettings<br/>singleton<br/>UUID 0...c8a7")]
TutorialEmbedding[("ims.TutorialEmbedding<br/>HANA Vector(1536)")]
SearchableItems[("ims.SearchableItems<br/>HANA full-text)")]
end
subgraph projections[Service projections]
AdminProj["AdminService.ChatSettings<br/>full surface<br/>(scope: Admin)"]
DevProj["DeveloperService.ChatConfig<br/>{enabled, bannerText}<br/>only โ public"]
end
subgraph tools[Tools registered conditionally]
ToolSearch["searchTutorials<br/>โ SearchableItems<br/>(LIMIT 5)"]
ToolRag["getRelevantSteps<br/>(only if ragEnabled)<br/>cosine similarity<br/>topK + minScore"]
end
subgraph pipeline[Embedding pipeline]
EmbedPub["embedding-pipeline.js<br/>(setImmediate after<br/>/content/publish)"]
EmbedReconcile["hourly reconcile :17<br/>contentHash drift"]
EmbedCleanup["daily 03:30<br/>orphan cleanup"]
end
end
subgraph aicore[SAP AI Core - managed service]
Orchestration["Orchestration Service<br/>scenario=orchestration<br/>v2/completion endpoint"]
Model["Foundation model<br/>(CHAT_MODEL_NAME or<br/>ChatSettings.modelName)<br/>default: claude-4.6-sonnet"]
EmbedModel["text-embedding-3-small<br/>(indexing + query)"]
end
subgraph admin[Admin shell]
AdminUi["Joule Settings page<br/>deploymentId, modelName,<br/>temperature, maxTokens,<br/>ragEnabled, bannerText"]
end
Trigger -->|"GET /api/ChatConfig<br/>(60s sessionStorage cache)"| ConfigRoute
ConfigRoute --> DevProj
DevProj -->|"{enabled, bannerText}"| JouleJs
JouleJs -->|"if disabled,<br/>remove trigger"| Trigger
Panel -->|"GET /auth/user<br/>(60s cache)"| AuthRoute
AuthRoute -->|"401 โ /login?joule=open"| Panel
Panel -->|"POST /chat/stream<br/>{messages, pageContext}"| ChatRoute
ChatRoute --> Bootstrap
Bootstrap -.->|after served| Served
Served --> RateLimit
RateLimit --> Orchestrator
Orchestrator --> ContextBuilder
ContextBuilder -.->|reads| ChatSettings
Orchestrator -.->|registers| ToolSearch
Orchestrator -.->|"if ragEnabled"| ToolRag
Orchestrator -->|"client.stream({messagesHistory})"| Orchestration
Orchestration --> Model
Model -->|delta chunks| Orchestration
Orchestration -->|"response.stream<br/>+ getToolCalls()"| Orchestrator
ToolSearch --> SearchableItems
ToolRag -->|"COSINE_SIMILARITY"| TutorialEmbedding
Orchestrator -->|"SSE: delta / tool /<br/>step-citations / done /<br/>error"| Panel
AdminUi -->|"OData CRUD<br/>(scope: Admin)"| AdminProj
AdminProj --> ChatSettings
EmbedPub -.->|upsert| TutorialEmbedding
EmbedReconcile -.-> TutorialEmbedding
EmbedCleanup -.-> TutorialEmbedding
EmbedPub -.->|embed text| EmbedModel
EmbedReconcile -.-> EmbedModel
ToolRag -.->|embed query| EmbedModel
EmbedModel -.->|via AI Core binding| Orchestration
classDef ext fill:#f4f4f4,stroke:#888,color:#333
class Orchestration,Model,EmbedModel ext
classDef storage fill:#e7f4ee,stroke:#15803d,color:#14532d
class ChatSettings,TutorialEmbedding,SearchableItems storage
classDef async fill:#fef3e7,stroke:#d97706,color:#92400e
class EmbedPub,EmbedReconcile,EmbedCleanup asyncNotes:
- Anonymous gating โ
GET /api/ChatConfigis the only public endpoint in the chat path. It exposes{ enabled, bannerText }so the trigger button can decide whether to render without forcing a login on visitors who never click.deploymentId,modelName,temperature,maxTokens, andmaxRequestsPerUsernever leave the server. - Lifecycle quirk โ
POST /chat/streamMUST be reserved oncds.on('bootstrap'), before CAP's OData router mountsChatServiceat/chat(which would otherwise try to parsestreamas a resource path โ 404). The handler is a late-bound stub that gets replaced with the realcontextMw โ authMw โ rateLimit โ businessHandlerchain onserved. Requests arriving in between get503 service_starting. - Two-projection trust split โ
AdminService.ChatSettings(full surface, scopeAdmin) drives the admin UI;DeveloperService.ChatConfig(3-field projection) is what the browser sees. Never widen the projection to{ * }. - Orchestration scenario, not model-direct โ
deploymentIdmust point to a deployment created with scenarioorchestration+ executableorchestrationin AI Launchpad. Model-direct deployments (Anthropic, Azure OpenAI direct) rejectv2/completionwith400 BadRequest. - BTP service dependencies โ
tutorials-srvrequires:four managed services for Joule (declared in ../../../.deploy/mta.yaml):tutorials-aicore(service: aicore, planextended) โ provides the AI Core endpoint URL + OAuth client credentials. Markedoptional: trueso the MTA still deploys without it, but/chat/streamreturns503until the binding exists. The@sap-ai-sdk/orchestrationSDK reads credentials directly fromVCAP_SERVICES.aicore[0].credentialsโ no manual env-var plumbing.tutorials-xsuaaโAdminscope gatesAdminService.ChatSettings; XSUAAsubclaim is the rate-limiter bucket key.tutorials-hanaโ persistsChatSettings(singleton row) andTutorialEmbedding(1,536-dim Vector column).tutorials-destinationโ not used by Joule directly; required by other srv code paths but listed here for completeness since the Joule binding shares the same app instance.
- AI Launchpad setup (one-time per subaccount) โ Joule needs two AI Core deployment UUIDs in
ChatSettings:- Entitle + subscribe โ in BTP Cockpit, entitle the subaccount to AI Core (
extendedplan) and AI Launchpad (standardplan), then subscribe to the AI Launchpad app and assign theAI_Adminrole collection to yourself. - Resource group โ open AI Launchpad โ select the AI Core instance bound to
tutorials-srvโ create or reuse a resource group (the defaultdefaultworks for single-tenant use). - Chat deployment โ Generative AI Hub โ Configurations โ + Create โ Scenario
orchestration, Executableorchestration, Version pinned, Save โ open the configuration โ Deploy โ wait for statusRUNNINGโ copy the deployment UUID. Paste into admin shell Joule Settings โ Deployment ID. - Embedding deployment (only if
ragEnabled) โ Configurations โ + Create โ Scenariofoundation-models, Executableazure-openai, Modeltext-embedding-3-small, Save โ Deploy โ copy UUID. Paste into admin shell Joule Settings โ Embedding Deployment ID and click Seed Embeddings Now for the first build (the hourly reconcile cron at:17catches subsequent drift). - Verify โ admin shell Joule Settings โ Test Connection issues a one-shot
client.stream()against the chat deployment; failure surfaces the upstream orchestration response body for diagnosis. See the "Diagnostic Recipe" section below for the canonicalcf logsgrep when this fails post-deploy.
- Entitle + subscribe โ in BTP Cockpit, entitle the subaccount to AI Core (
- Multi-turn tool loop โ capped at
MAX_TURNS = 5. The model can invokesearchTutorialsand (ifragEnabled)getRelevantStepsin any turn; the orchestrator runs the tool, pushes the result onto the message history, and re-streams. - RAG is conditional and async-fed โ
getRelevantStepsonly registers as a tool whenChatSettings.ragEnabledis true. Embeddings are populated bysetImmediateafterPOST /content/publish(non-blocking), reconciled hourly at minute:17oncontentHashdrift, and cleaned daily at 03:30 for orphans. On HANA, queries use raw SQL with theCOSINE_SIMILARITYoperator; SQLite tests fall back to JS-side cosine. - Rate limiter is in-memory โ bucket key is the XSUAA
subclaim. Acf restartresets every user's counter to zero, so the cap is best-effort, not a hard billing guard. - Default state is OFF โ
ChatSettings.enableddefaults tofalseon first deploy. There is no env-var override; an admin must explicitly enable Joule via the admin shell.
Reference โ
The "Joule" in-page chat assistant on the tutorial portal: a contextual, page-aware LLM chat backed by SAP AI Core's Orchestration Service via @sap-ai-sdk/orchestration.
At a Glance โ
| Concern | Where it lives |
|---|---|
| Trigger button + panel markup | hugo/layouts/partials/joule-panel.html |
| Panel styling | hugo/static/css/joule.css |
| Browser logic (SSE consumer) | hugo/static/js/joule.js |
| Public config endpoint | GET /api/ChatConfig (DeveloperService projection) |
| Streaming endpoint | POST /chat/stream (custom Express, srv/server.js:103) |
| Orchestration logic | srv/lib/chat-orchestrator.js |
| System prompt builder | srv/lib/chat-context.js |
| Per-user rate limiter | srv/lib/chat-rate-limit.js |
| Settings entity (DB) | ims.ChatSettings (db/schema.cds:340) |
| Admin surface | AdminService.ChatSettings (full surface, singleton at fixed UUID) |
| Public projection | DeveloperService.ChatConfig (only enabled + bannerText) |
| AppRouter routes | approuter/xs-app.json (^/api/ChatConfig, ^/chat/) |
Architecture โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Browser (Hugo page) โ
โ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ joule-trigger โ โ joule-panel (transcript, form) โ โ
โ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ click โฒ SSE deltas โ
โ โผ โ โ
โ loadConfig() โโโโ GET /api/ChatConfig (anonymous) โ
โ โ โ
โ โผ (if enabled) โ
โ ensureAuth() โโโโ GET /auth/user โโโโ 401 โ redirect /login โ
โ โ โ
โ โผ โ
โ send() โโโโโโโโโโโบ POST /chat/stream {messages, pageContext} โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ AppRouter (xsuaa)
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CAP server (tutorials-srv) โ
โ bootstrap: reserves POST /chat/stream BEFORE ChatService mounts โ
โ served: binds real handler (context โ auth โ rate โ stream) โ
โ โ
โ streamChat() โโโบ OrchestrationClient(...).stream({messagesHistory})โ
โ โ
โ tool dispatch: searchTutorials โ SearchService.SearchableItems โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
SAP AI Core / Orchestration Service
(deployment: scenario=orchestration)
โ
โผ
gpt-4.1 (or env override)Data Model โ
ims.ChatSettings is a singleton (one row, fixed UUID 00000000-0000-0000-0000-00000000c8a7, seeded by before('READ') in srv/admin-service.js:31-44).
entity ChatSettings : cuid, managed {
enabled : Boolean default false; // master kill-switch
deploymentId : String(100); // AI Core orchestration deployment ID
modelName : String(100); // foundation model (e.g. anthropic--claude-4.6-sonnet); blank = server default
temperature : Decimal(3, 2); // sampling temperature 0.00โ1.00; blank = server default
maxTokens : Integer; // assistant response token cap; blank = server default
maxRequestsPerUser : Integer default 100; // per-user, 24h rolling
bannerText : String(500); // shown above transcript
}Two projections:
AdminService.ChatSettingsโ full surface, drives the Joule Settings admin page.DeveloperService.ChatConfigโ public, exposes onlyID,enabled,bannerText. ThedeploymentId,modelName,temperature,maxTokens, andmaxRequestsPerUsernever leave the server.
Routing โ
| Source pattern | Auth | Purpose |
|---|---|---|
^/api/ChatConfig.* | none | Anonymous trigger gating โ loadConfig() runs before login |
^/chat/.* | xsuaa | All POSTs to /chat/stream require a valid IDP session |
^/auth/user | xsuaa | Returns the authenticated user profile (used to greet by first name) |
The /api/ChatConfig route is intentionally public โ it's how the trigger button decides whether to render at all without forcing an unwanted login on visitors who never click it.
Server Lifecycle Quirk โ
CAP's OData router mounts ChatService at /chat. If we registered the streaming handler as a normal middleware after served, the OData router would intercept POST /chat/stream and try to parse stream as a resource path โ 404.
The fix in srv/server.js:
bootstrapevent (line 103):app.post('/chat/stream', express.json(...), dispatcher)is registered whilecds.appis still a plain Express app, BEFORE OData routes mount.- The
dispatcheris a late-bound stub:(req, res, next) => chatStreamHandler(req, res, next). servedevent (line 199):chatStreamHandleris replaced with the realcontextMw โ authMw โ businessHandlerchain โ which can now safely referencecds.middlewares(which only exists once CAP is fully wired).
Race-condition safe: any request that arrives before served returns 503 service_starting from the initial stub.
OrchestrationClient Configuration โ
Per @sap-ai-sdk/orchestration 2.10.0:
new OrchestrationClient(
{
promptTemplating: {
model: { name: 'gpt-4.1' }, // or env CHAT_MODEL_NAME
prompt: {
template: [{ role: 'system', content: <system prompt> }],
tools: [SEARCH_TUTORIALS_TOOL],
},
},
},
{ deploymentId }, // 2nd arg, NOT inside config
);Critical: deploymentId must point to an orchestration-scenario deployment in AI Launchpad โ not a foundation-model-direct deployment. The SDK calls v2/completion, which is only valid for the orchestration scenario. A model-direct deployment (Anthropic, Azure OpenAI direct, etc.) will return:
400 BadRequest: Subpath 'v2/completion' is not allowed for model 'X'.To create the right deployment in AI Launchpad: Generative AI Hub โ Configurations โ +Create โ Scenario orchestration โ Executable orchestration โ Save โ Deploy, then copy the resulting deployment UUID into the admin Joule Settings page.
Streaming Loop โ
srv/lib/chat-orchestrator.js:80-132:
const response = await client.stream({ messagesHistory: history }, signal);
for await (const chunk of response.stream) {
const delta = chunk.getDeltaContent?.();
if (delta) {
assistantText += delta;
sse(res, { type: 'delta', content: delta });
}
}
// Tool calls are NOT delivered per-chunk on this SDK โ pull them once after streaming completes:
const finalToolCalls = response.getToolCalls?.();Two commonly-missed details:
client.stream(...)returns a Promise that resolves to anOrchestrationStreamResponse. The async-iterable lives onresponse.stream, not on the promise itself.for await (const chunk of client.stream(...))(without theawait) iterates the Promise object itself, which yields nothing.OrchestrationStreamChunkResponse.getDeltaToolCalls()returns fragment tool calls per chunk. Final assembled tool calls come fromresponse.getToolCalls()after the stream completes.
A multi-turn agent loop (capped at MAX_TURNS = 5) handles tool dispatch:
turn 0: model emits tool call(s) โ server runs searchTutorials โ push tool result onto history
turn 1: model produces final assistantText โ emit {type:'done'} โ returnSystem Prompt Layering โ
srv/lib/chat-context.js composes three layers:
- PERSONA โ fixed: "You are Joule, an AI assistant embedded in the SAP Tutorial Platform. You ONLY answer questions about SAP tutorials..."
- Page layer โ varies by
pageContext.kind:tutorialโ current slug, title, tags, current stepsearchโ current query + active filtersmission/groupโ current container slug + titledefaultโ empty
- User layer โ
Hello {firstName}greeting hint.
pageContext is read in the browser by readPageContext() from <html data-page-kind="..." data-page-slug="..." ...> attributes that Hugo's baseof.html sets on every page.
Devtoberfest scope โ
On pages under /devtoberfest/** (or any page declaring frontmatter joule_scope: devtoberfest), pageContext.kind is 'devtoberfest' and Joule switches to a scoped persona:
- Tools available:
searchTutorials(the persona instructs the model to passtags: ['devtoberfest']) andgetDevtoberfestInfo(reads theDevtoberfestConfigsingleton +currentEvent). - Tools suppressed on this kind regardless of
ChatSettings:getUserProgress,getRelevantSteps,checkCode,getBranchRecommendation,findLearningPath, and the admin analytics tools. - Scope policy: Devtoberfest event + Devtoberfest-tagged tutorials + general Devtoberfest knowledge + SAP TechEd as adjacent. Everything else is politely refused.
- Forward-compat:
getDevtoberfestInforeturns{ available: false, comingSoon: true }for points, gameboard, activities, and videos sections. When schema fields land for those data domains, the handler's section builder flips to a populated shape โ the tool's LLM-facing schema does not change.
Spec: docs/superpowers/specs/2026-06-23-565-joule-devtoberfest-design.md.
Tool: searchTutorials โ
The single registered tool. Invoked when the model decides the user is asking about tutorials other than the current one (or when no current tutorial context exists).
{
"type": "function",
"function": {
"name": "searchTutorials",
"description": "Search the SAP tutorial catalog...",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"type": { "type": "string", "enum": ["tutorial", "mission", "group"] }
},
"required": ["query"]
}
}
}Server-side dispatch (chat-orchestrator.js:32-53) runs a SELECT.from('SearchableItems').where({ search: query, ...filters }) .limit(5) against the SearchService, returning up to 5 hits with slug, title, description, type, primaryTag.
Tutorial Grounding (RAG) โ
When enabled, the getRelevantSteps tool grounds the chat in per-step embeddings from published tutorials, allowing the model to cite specific tutorial steps as evidence for its answers.
How it works โ
- Each tutorial step (from the active content manifest) gets embedded via
text-embedding-3-small(AI Core). - Embeddings are stored in the HANA table
TutorialEmbedding(1,536-dimensionalVectorcolumn). - On each chat message, the orchestrator calls
getRelevantSteps(userQuestion)ifChatSettings.ragEnabledis true. - The server runs a cosine-similarity query against all embeddings, returning the top
embeddingTopKmatches withscore >= embeddingMinScore. - When
getRelevantStepsreturns matches, the orchestrator emits an SSEstep-citationsevent ahead of the assistant's text delta with{ items: [{ slug, stepNumber, score, excerpt }] }. The server emits citations in the canonical form[tutorial-slug #stepNumber]; the assistant is also instructed to inline-cite using the same notation. Frontend rendering of the dedicatedstep-citationsevent is not yet wired up โ until it is, citations appear only inline in the streamed assistant text.
Configuration โ
Feature flag and tuning knobs live in ChatSettings (admin UI):
| Setting | Default | Description |
|---|---|---|
ragEnabled | false | Master toggle. When off, getRelevantSteps is not registered; when on, tool is available for the model to invoke. |
embeddingModel | text-embedding-3-small | AI Core model for both indexing and query time. |
embeddingTopK | 5 | Max number of step matches returned per query. |
embeddingMinScore | 0.25 | Cosine similarity floor; below this, matches are dropped. |
Implementation notes โ
- On HANA, embeddings are queried via raw SQL (
db.run()withCOSINE_SIMILARITYoperator). Unit tests (SQLite) use JavaScript-side cosine calculation. - The embedding pipeline runs automatically after
POST /content/publishcompletes. It upserts embeddings for changed slugs viasetImmediateto avoid blocking the publish response. - Hourly reconciliation cron (minute
:17) re-embeds steps if theircontentHashhas changed, and fills in any missing rows. - Daily cleanup at 03:30 removes stale embeddings for tutorials no longer in the active manifest.
Operations โ
See Joule Chat Admin Settings for the admin runbook: first-time seeding, recovering from drift, reading stats, and rotating the embedding model.
Frontend Behaviour โ
- Lazy enable โ
loadConfig()GETs/api/ChatConfig(anonymous, sessionStorage cached for 60s). Ifenabled === false, the trigger is removed from the DOM and no further chat code runs. - Auth gate โ
ensureAuth()checks<html data-authenticated="...">, thensessionStorage, thenGET /auth/user(60s cache). If unauthenticated, the panel redirects to/login?returnTo=<path>?joule=open. After XSUAA bounces back, thejoule=openquery param re-opens the panel automatically and is stripped from the URL viahistory.replaceState. - History โ last N messages stored in
sessionStorageunderjoule.history. Eachsend()POSTs the full array asmessages, plus currentpageContext. - SSE consumer โ parses
data:lines, dispatches onpayload.type:deltaโ append text to the assistant bubbletoolโ render a "Searching for ..." chip above the bubbledoneโ persist to historyerrorโ replace bubble with friendly text (content_filterreason gets a different message)
- Stale guard โ every
send()incrementsactiveSendId; if a new send starts mid-stream, the in-flight reader bails after the next chunk. Prevents races when the user submits twice quickly. - DOM mutation safety โ message bubbles are added with
createElement/textContent/replaceChildren; the project security hook blocks any DOM-string-mutation patterns (assigning HTML strings into element properties), which would let arbitrary model output execute as markup.
Operational Lifecycle โ
Default OFF on first deploy โ
ChatSettings.enabled defaults to false. The trigger button is removed client-side when /api/ChatConfig returns { enabled: false }, so the feature is invisible until an admin explicitly turns it on.
Turning Joule on in DEV โ
- Deploy the MTA โ
tutorials-srvboots withenabled = false. - Provision an orchestration-scenario deployment in AI Launchpad (see "OrchestrationClient Configuration" above). Copy the deployment UUID.
- In the admin shell โ Joule Settings:
- Paste the deployment UUID into Deployment ID.
- Set Enabled = true.
- Optionally set Banner text ("Joule is in beta โ please report issues").
- Save.
- Hard-reload a Hugo page. The trigger appears within 60 seconds (the
/api/ChatConfigcache TTL).
Turning it off (kill-switch) โ
Set Enabled = false in admin and save. Existing in-flight streams complete; new requests get 503 disabled from the server, and after the 60s cache TTL the trigger disappears from new page loads.
Rate limiting โ
Per-user, per-day rolling window. Bucket key is user.id (XSUAA sub claim). When a user hits maxRequestsPerUser (default 100), the next /chat/stream returns 429 rate_limit with retryAfterSec. The browser shows "You've reached today's chat limit." The counter is in-memory โ it resets on tutorials-srv restart, so the cap is best-effort, not a hard billing guard. For a stricter cap, push state to HANA or a Redis-equivalent service.
Switching models โ
Set CHAT_MODEL_NAME env var on tutorials-srv (e.g. gpt-4.1, anthropic--claude-4.5-haiku). The orchestration deployment routes to whatever model name we pass โ no redeploy of AI Core needed. Default is anthropic--claude-4.6-sonnet (matches Joule Studio).
cf set-env tutorials-srv CHAT_MODEL_NAME gpt-4.1
cf restart tutorials-srvFailure Modes โ
| Symptom | Cause | Fix |
|---|---|---|
502 Bad Gateway: Registered endpoint failed... | OrchestrationClient threw synchronously at construction (config shape was wrong โ uncaught โ worker crashed mid-request) | Constructor is now wrapped in try/catch (chat-orchestrator.js:58-76) โ emits {type:'error'} SSE frame and 200 |
200 OK + empty SSE body + "Something went wrong." | client.stream() rejected. Check cf logs for chat stream failed line โ | body: {...} shows the orchestration response. Common causes: wrong deployment scenario, invalid model name, AI Core scope missing. | Check / fix deployment ID; check binding has the right scopes |
503 disabled | enabled = false or deploymentId empty in ChatSettings | Toggle Enabled + paste deployment ID in admin |
401 unauthenticated | XSUAA session expired | Browser redirects to /login?returnTo=...?joule=open; auto-reopens after callback |
429 rate_limit | Per-user 24h cap hit | Wait retryAfterSec or admin raises maxRequestsPerUser |
error.reason === 'content_filter' | Orchestration's input/output filter rejected the message | Browser shows "I can't help with that..." โ by design |
unknown_tool in SSE tool result | Model invented a tool name we don't expose | Logged + ignored; loop continues |
Diagnostic Recipe โ
When a chat call fails, the canonical first step is:
cf logs tutorials-srv --recent | grep -E "chat stream failed|registered" | tail -20The error log line includes | body: {...} with the upstream orchestration response body. That body is the source of truth โ err.message alone ("Request failed with status code 400") is just the axios summary.
Testing โ
Currently no automated tests for the streaming path โ hard to mock OrchestrationClient.stream() realistically.
Manual test plan:
- Trigger gating โ set
enabled = false, hard reload โ trigger button must not appear. - Login redirect โ open trigger while logged out โ should redirect to
/login?returnTo=...?joule=openand re-open on return. - Greeting โ fresh session, open panel โ must show "Hello {firstName}, How can I help you?" if first name is in the IDP token.
- Stream a response โ type a tutorial-related question โ must see token-by-token streaming in the assistant bubble.
- Tool call โ ask "find tutorials about ABAP cloud" โ must see "Searching for ..." chip, then synthesised response referencing real tutorial slugs.
- Off-topic refusal โ ask "what's the weather?" โ model must decline (PERSONA layer).
- Rate limit โ temporarily set
maxRequestsPerUser = 2, send 3 messages โ third must show "You've reached today's chat limit." - Kill switch โ set
enabled = falsemid-session โ wait 60s โ new page loads must not show trigger.
Tool: findLearningPath (Phase 2 of #381, issue #445) โ
Hybrid pathBetween Joule tool. Translates natural-language prompts ("I want to build a CAP service with Fiori UI") into an ordered tutorial sequence by routing through KG_QUERY.hdbprocedure's 3-arm UNION SPARQL.
- Registration gate โ registered when
ChatSettings.enabled = true && ChatSettings.kgPathBetweenEnabled = true. WhenkgPathBetweenEnabled = false(default), the tool is not registered and the LLM won't see it. - Tool descriptor โ explicit positive triggers (
LEARN,NEXT,path/order) plus negative-space callouts ("DO NOT use this tool when... usegetRelevantSteps... usecheckCode") to push the LLM away from sibling tools. Full descriptor in srv/lib/kg/joule-tool-find-path.js. - Params โ
toSlug(required),fromSlug?(optional โ defaults to user's most-recent COMPLETED tutorial, or unanchored mode if no history).userIdflows transparently fromreq.user.id; not an LLM-visible parameter. - Hybrid SPARQL strategy โ three UNION arms in db/src/procedures/KG_QUERY.hdbprocedure:
- PREREQ (rank 1) โ
?a kg:teaches/(^kg:requires)+/kg:teaches ?bโ preferred when prereq edges exist - CO_COMPLETED (rank 2) โ
?a (kg:coCompletedWith)+ ?bโ behavioral signal (dense, ~13k edges) - SHARED_CONCEPT (rank 3) โ
?a kg:teaches ?c. ?b kg:teaches ?c.โ semantic, always-on Results are merged + sorted bypathTypeRank ASC, capped atLIMIT 10.
- PREREQ (rank 1) โ
- Why
+not{1,5}โ HANA KGE doesn't support{n,m}counted-range property paths (returnsUnsupported functionality: Path repeat range). Closure (+) plusLIMIT 10+ the kgQuery 5s timeout bound depth indirectly. Probed and confirmed via Task 0 spike of #445. - JS-side post-processing โ handler dedups by slug (lowest rank wins), promotes the LLM-named
toSlugto position 1 if it appears in the candidate set ("exactTargetReached"), optionally filters out fully-user-covered candidates (excepttoSlugitself, which is never filtered), hydrates withTutorials.title+Tutorials.estimatedTimeMinutes. - Coverage filter โ when
user.idis present, callsgetConceptsForUser({ db, userId })(srv/lib/kg/concepts-for-user.js) which joinsTaskRecords WHERE taskType='TUTORIAL'againstTutorials.legacyIdand readskg:teachesedges viaKG_ADMIN_RUNSPARQLwith aVALUESclause. Returns{ learned: <concept-slugs>, partial: ... }. The handler drops candidates whose ALL taught-concepts are inlearned, never drops thetoSlugitself even when fully covered. - Return shape โ markdown numbered list rendered by the handler; LLM paraphrases or quotes verbatim. Format:
1. **<title>** โ [<slug>](https://developers.sap.com/tutorials/<slug>.html)\n ~<minutes> min ยท <reason>where reason is"Prerequisite chain"/"Often completed together"/"Shares concepts". - Telemetry โ emits
kg.joule.path_requested({ fromSlug, toSlug, hasUserId, fromSlugInferred, unanchored }) at dispatch start,kg.joule.path_returned({ ..., resultCount, pathTypeBreakdown: { PREREQ, CO_COMPLETED, SHARED_CONCEPT }, latencyMs, fromSlugInferred, exactTargetReached, error? }) at dispatch end including error paths. - Error envelopes โ handler returns friendly strings for the LLM to paraphrase: malformed
toSlug/fromSlugvalidation errors,SparqlTimeoutError(5s budget exceeded),SparqlSyntaxError, empty result set. - Path engine โ when
KG_PATH_V2_ENABLED='true'(DEV default on, #1253), the tool routes throughKG_PATH_V2(HANA GraphScriptSHORTEST_PATHoverKG_PG_WORKSPACE, #913) viasrv/lib/kg-path.js::findPathV2OrV1, which computes a true shortest AโB path โ the named destination is guaranteed to appear as the final step, or an explicit "couldn't find a path" message is returned. The path istutorial:A โ concept:โฆ โ tutorial:B; interior concepts are surfaced on the destination step as "Connected via: โฆ". When the flag is off, or v2 returns empty / errors, it fails open to the v1 SPARQLPATH_BETWEENbranch inKG_QUERY.hdbprocedureโ which references only:p1in its 3-arm UNION body and thus returns the source's closest topical neighbors (the pre-#1253 behavior). - AI-judge fixture โ test/hybrid/joule-tool-pick-find-path.test.js โ 12 prompts assert the LLM picks the right tool (findLearningPath vs getRelevantSteps vs checkCode vs no-tool). Pass threshold โฅ90% (11/12). Gated by
HYBRID_AI_TESTS=true; default test:hybrid runs at $0. Regression guard against descriptor changes.
Implementation: srv/lib/kg/joule-tool-find-path.js (handler + descriptor) + srv/lib/kg/concepts-for-user.js (coverage helper).
Recent Changes โ
- 2026-05-19 โ Migrated
OrchestrationClientconfig to SDK 2.10.0 shape (promptTemplating: { model, prompt: { template, tools } }) + extracteddeploymentIdto 2nd constructor arg. Wrapped construction in try/catch to fix 502s. Switched streaming iteration toawait client.stream(...)thenfor await (...response.stream). Pulled tool calls fromresponse.getToolCalls()post-stream. Enhanced error logging to include upstream response body. - Earlier โ Initial implementation: in-page trigger + panel,
/api/ChatConfigpublic projection,/chat/streamSSE endpoint,searchTutorialstool, page-context system prompt, sessionStorage history,?joule=openauto-reopen after login redirect.
Gotchas โ
- Default state is OFF. First deploy must be followed by an admin enabling the feature. There is no env-var override.
deploymentIdis inChatSettings, not env vars. This is intentional โ admins should be able to swap models without an MTA redeploy. Setting it viacf set-envdoes nothing.- Public projection has 3 fields only. If you need to expose another setting to the browser, add it to
DeveloperService.ChatConfigexplicitly. Never widen the projection to{ * }. - OData mounts at
/chat. Custom Express routes forPOST /chat/...MUST be registered inbootstrap, notserved. - Orchestration deployment, not model-direct. See "OrchestrationClient Configuration" above. The SDK calls
v2/completion, which only works on orchestration-scenario deployments. - DOM-string-mutation patterns are blocked by a project security hook โ every assistant chunk goes through
textContent(or viareplaceChildren()to clear the transcript). Don't try to assign rendered HTML to element properties: the hook will refuse the edit. If markdown rendering is added later, sanitise + convert to a DOM tree manually. - Rate limiter is in-memory. A
cf restartresets every user's counter to zero. For a hard cap, replacechat-rate-limit.jswith a HANA-backed implementation. - Per-chunk tool calls return null for this SDK. Use
response.getToolCalls()after the stream completes, NOTchunk.getToolCalls()(onlygetDeltaToolCalls()exists on chunks).
Related Docs โ
- authentication.md โ XSUAA / IDP flow that wraps
/chat/stream - build.md โ how tutorial content (which the search tool returns) gets into HANA
- mta-deployment.md โ
tutorials-srvdeploy procedures