cap-copilot-sdk 0.1.1 → 0.2.2

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.
package/src/Registrar.js CHANGED
@@ -1,74 +1,253 @@
1
1
  "use strict";
2
2
  const https = require("https");
3
- const http = require("http");
3
+ const http = require("http");
4
4
  const { URL } = require("url");
5
5
 
6
- /**
7
- * Register (or update) the app context documents with the BTP Copilot backend.
8
- *
9
- * POST /api/apps/register
10
- * Body: { app_id, app_name, documents, replace }
11
- *
12
- * @param {{ backendUrl: string, appId: string, appName: string,
13
- * documents: {title:string, content:string}[],
14
- * token?: string }} opts
15
- * @returns {Promise<boolean>}
16
- */
17
- async function register(opts) {
18
- const { backendUrl, appId, appName, documents, token } = opts;
6
+ const SDK_VERSION = (() => {
7
+ try { return require("../package.json").version; } catch { return "unknown"; }
8
+ })();
9
+ // Timeout for every outbound HTTP request to the backend.
10
+ // Override via BTP_COPILOT_REQUEST_TIMEOUT_MS env var if needed.
11
+ const REQUEST_TIMEOUT_MS = Number(process.env.BTP_COPILOT_REQUEST_TIMEOUT_MS) || 15_000;
19
12
 
20
- // Backend allows max 50 documents — trim if needed
21
- const docs = documents.slice(0, 50);
13
+ // ── Logging helpers ───────────────────────────────────────────────────────────
14
+ const _log = (...a) => console.log ("\x1b[36m[btp-copilot]\x1b[0m", ...a);
15
+ const _warn = (...a) => console.warn("\x1b[33m[btp-copilot]\x1b[0m", ...a);
22
16
 
23
- const url = new URL("/api/apps/register", backendUrl);
24
- const body = JSON.stringify({
25
- app_id: appId,
26
- app_name: appName ?? appId,
27
- documents: docs,
28
- replace: true,
29
- });
17
+ // ── Concurrency limiter ───────────────────────────────────────────────────────
18
+ // At most MAX_CONCURRENT outbound HTTP requests to the Python backend at one
19
+ // time (per Node.js process / CAP app instance). When a startup sync or a
20
+ // burst of watchEntity mutations fires many simultaneous requests, the excess
21
+ // wait here rather than landing all at once on the backend.
22
+ const MAX_CONCURRENT = 3;
23
+ let _active = 0;
24
+ const _waitQueue = [];
30
25
 
26
+ function _acquire() {
31
27
  return new Promise((resolve) => {
32
- const lib = url.protocol === "https:" ? https : http;
33
- const headers = {
34
- "Content-Type": "application/json",
35
- "Content-Length": Buffer.byteLength(body),
36
- };
37
- if (token) headers["Authorization"] = `Bearer ${token}`;
28
+ if (_active < MAX_CONCURRENT) { _active++; resolve(); }
29
+ else _waitQueue.push(resolve);
30
+ });
31
+ }
32
+
33
+ function _release() {
34
+ // Hand the slot to the next waiter without changing _active; only decrement
35
+ // when there is truly nobody waiting.
36
+ if (_waitQueue.length > 0) _waitQueue.shift()();
37
+ else _active--;
38
+ }
39
+
40
+ // ── Circuit breaker ───────────────────────────────────────────────────────────
41
+ // Protects the backend when it is down or overloaded.
42
+ // CLOSED → normal operation.
43
+ // After CB_THRESHOLD consecutive failures → OPEN: all requests are dropped for
44
+ // CB_RESET_MS ms so the backend gets breathing room.
45
+ // After CB_RESET_MS → HALF_OPEN: one probe request is allowed through.
46
+ // Success → CLOSED. Failure → OPEN again (timer resets).
47
+ const CB_THRESHOLD = 5;
48
+ const CB_RESET_MS = 60_000; // 1 minute
49
+
50
+ const _cb = { state: "CLOSED", failures: 0, openedAt: 0 };
51
+
52
+ function _cbAllow() {
53
+ if (_cb.state === "CLOSED" || _cb.state === "HALF_OPEN") return true;
54
+ if (Date.now() - _cb.openedAt >= CB_RESET_MS) { _cb.state = "HALF_OPEN"; return true; }
55
+ return false;
56
+ }
57
+ function _cbSuccess() { _cb.state = "CLOSED"; _cb.failures = 0; }
58
+ function _cbFailure() {
59
+ _cb.failures++;
60
+ if (_cb.state === "HALF_OPEN" || _cb.failures >= CB_THRESHOLD) {
61
+ _cb.state = "OPEN";
62
+ _cb.openedAt = Date.now();
63
+ _warn(`Circuit breaker OPEN — backend unreachable. Pausing all SDK requests for ${CB_RESET_MS / 1000}s.`);
64
+ }
65
+ }
66
+
67
+ // ── Retry with exponential backoff + jitter ───────────────────────────────────
68
+ // 5xx / network errors → RetryableError (up to MAX_RETRIES attempts).
69
+ // 4xx errors (auth, bad request) → return false immediately; no retry needed.
70
+ const MAX_RETRIES = 3;
71
+ const BASE_WAIT_MS = 1000;
72
+
73
+ class RetryableError extends Error {}
74
+
75
+ function _sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
76
+
77
+ async function _withRetry(fn) {
78
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
79
+ if (!_cbAllow()) {
80
+ _warn("Circuit breaker OPEN — request skipped to protect backend.");
81
+ return false;
82
+ }
83
+ await _acquire();
84
+ try {
85
+ const ok = await fn();
86
+ _cbSuccess();
87
+ return ok; // false = non-retryable 4xx; true = success
88
+ } catch (e) {
89
+ _cbFailure();
90
+ if (e instanceof RetryableError && attempt < MAX_RETRIES) {
91
+ const delay = BASE_WAIT_MS * Math.pow(2, attempt) + Math.random() * 600;
92
+ _warn(`Retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay)}ms — ${e.message}`);
93
+ await _sleep(delay);
94
+ } else {
95
+ _warn("Request failed:", e.message);
96
+ return false;
97
+ }
98
+ } finally {
99
+ _release();
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+
105
+ // ── Reusable TCP agents ───────────────────────────────────────────────────────
106
+ const _agents = {
107
+ http: new http.Agent ({ keepAlive: true, maxSockets: 4 }),
108
+ https: new https.Agent({ keepAlive: true, maxSockets: 4 }),
109
+ };
110
+
111
+ // ── Low-level HTTP POST ───────────────────────────────────────────────────────
112
+ // Resolves: true → 2xx success
113
+ // false → 4xx (config/auth error; caller should not retry)
114
+ // Throws: RetryableError → 5xx or network error; caller should retry
115
+ function _post(backendUrl, apiPath, payload, token, label) {
116
+ const url = new URL(apiPath, backendUrl);
117
+ const body = JSON.stringify(payload);
118
+ const isHttps = url.protocol === "https:";
119
+ const lib = isHttps ? https : http;
120
+ const agent = isHttps ? _agents.https : _agents.http;
121
+
122
+ const headers = {
123
+ "Content-Type": "application/json",
124
+ "Content-Length": Buffer.byteLength(body),
125
+ "X-BTP-Copilot-SDK-Version": SDK_VERSION,
126
+ };
127
+ if (token) headers["Authorization"] = `Bearer ${token}`;
128
+
129
+ _log(`POST ${url.href}`);
38
130
 
131
+ return new Promise((resolve, reject) => {
39
132
  const req = lib.request(
40
133
  {
41
134
  hostname: url.hostname,
42
- port: url.port || (url.protocol === "https:" ? 443 : 80),
43
- path: url.pathname + url.search,
44
- method: "POST",
135
+ port: url.port || (isHttps ? 443 : 80),
136
+ path: url.pathname + url.search,
137
+ method: "POST",
45
138
  headers,
46
- // Accept self-signed certs in dev environments
47
- rejectUnauthorized: process.env.NODE_ENV !== "production",
139
+ agent,
140
+ // Always verify TLS certificates. Set BTP_COPILOT_TLS_INSECURE=1 ONLY for
141
+ // local dev with self-signed certs. Never disable in production.
142
+ rejectUnauthorized: process.env.BTP_COPILOT_TLS_INSECURE !== "1",
143
+ timeout: REQUEST_TIMEOUT_MS,
48
144
  },
49
145
  (res) => {
50
- let body = "";
51
- res.on("data", (chunk) => (body += chunk));
146
+ let data = "";
147
+ res.on("data", (c) => (data += c));
52
148
  res.on("end", () => {
53
- const ok = res.statusCode >= 200 && res.statusCode < 300;
54
- if (!ok) {
55
- console.warn(
56
- `[btp-copilot] Registration HTTP ${res.statusCode}: ${body.slice(0, 200)}`,
57
- );
149
+ if (res.statusCode >= 200 && res.statusCode < 300) {
150
+ resolve(true);
151
+ } else if (res.statusCode >= 500) {
152
+ reject(new RetryableError(`HTTP ${res.statusCode}`));
153
+ } else if (res.statusCode === 404 && label === "service-tool") {
154
+ // Backend does not yet support live-query tool registration — not an error
155
+ _log("Backend does not yet support OData service tool registration (404) — using vector store only.");
156
+ resolve(false);
157
+ } else {
158
+ _warn(`Backend responded ${res.statusCode}: ${data.slice(0, 300)}`);
159
+ resolve(false);
58
160
  }
59
- resolve(ok);
60
161
  });
61
162
  },
62
163
  );
63
-
64
- req.on("error", (err) => {
65
- console.warn("[btp-copilot] Registration network error:", err.message);
66
- resolve(false);
164
+ req.on("timeout", () => {
165
+ req.destroy(new RetryableError(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`));
67
166
  });
68
-
167
+ req.on("error", (e) => reject(new RetryableError(e.message)));
69
168
  req.write(body);
70
169
  req.end();
71
170
  });
72
171
  }
73
172
 
74
- module.exports = { register };
173
+ // ── Public API ────────────────────────────────────────────────────────────────
174
+
175
+ async function register(opts) {
176
+ const { backendUrl, appId, appName, documents, token, appBaseUrl, serviceUrl } = opts;
177
+ const replace = opts.replace !== false;
178
+
179
+ _log(`Sending ${documents.length} doc(s) to backend (appId="${appId}")…`);
180
+
181
+ const ok = await _withRetry(() =>
182
+ _post(backendUrl, "/api/apps/register", {
183
+ app_id: appId,
184
+ app_name: appName ?? appId,
185
+ // Always include base URL and service path so the backend stores them in
186
+ // the app registry. When the backend restarts it loses service-tool
187
+ // registrations but still has these values from the most recent register
188
+ // call, allowing live OData queries to work without a CAP restart.
189
+ app_base_url: appBaseUrl ?? "",
190
+ service_url: serviceUrl ?? "",
191
+ documents,
192
+ replace,
193
+ }, token, "register")
194
+ );
195
+
196
+ if (ok) _log(`✔ Backend accepted ${documents.length} docs — indexing in background.`);
197
+ return ok;
198
+ }
199
+
200
+ /**
201
+ * Register one CAP OData service as a live query tool with the chatbot backend.
202
+ * Call once per service URL (use registerAllServiceTools for multi-service apps).
203
+ *
204
+ * Flow WITHOUT this: user asks → search vector store → LLM answers from cached text
205
+ * Flow WITH this: user asks → LLM calls OData tool → gets live rows → LLM answers
206
+ *
207
+ * @param {{ backendUrl, appId, appName, serviceUrl, entities: string[], token }} opts
208
+ */
209
+ async function registerServiceTool(opts) {
210
+ const { backendUrl, appId, appName, serviceUrl, entities, entityFields, token, appBaseUrl } = opts;
211
+ if (!serviceUrl) return false;
212
+
213
+ _log(`Registering OData tool — appId="${appId}" service="${serviceUrl}" entities=${(entities ?? []).length}`);
214
+
215
+ const ok = await _withRetry(() =>
216
+ _post(backendUrl, "/api/apps/register-service-tool", {
217
+ app_id: appId,
218
+ app_name: appName ?? appId,
219
+ service_url: serviceUrl,
220
+ entities: entities ?? [],
221
+ entity_fields: entityFields ?? {},
222
+ app_base_url: appBaseUrl ?? "",
223
+ }, token, "service-tool")
224
+ );
225
+
226
+ if (ok) _log(`✔ OData service tool registered — chatbot can now query live data directly.`);
227
+ return ok;
228
+ }
229
+
230
+ /**
231
+ * Register multiple OData service URLs for a multi-service CAP app.
232
+ * Each service is registered as a separate live query tool on the backend.
233
+ *
234
+ * Config example (package.json):
235
+ * "serviceUrls": ["/odata/v4/SalesService", "/odata/v4/PurchaseService"]
236
+ *
237
+ * @param {{ backendUrl, appId, appName, serviceUrls: string[], entities: string[], token }} opts
238
+ * @returns {Promise<boolean[]>} one result per URL
239
+ */
240
+ async function registerAllServiceTools(opts) {
241
+ const { serviceUrls = [], entityFieldsMap = {}, ...rest } = opts;
242
+ if (!serviceUrls.length) return [];
243
+ // Run registrations sequentially to respect the concurrency limiter
244
+ const results = [];
245
+ for (const serviceUrl of serviceUrls) {
246
+ // entityFieldsMap is keyed by serviceUrl; fall back to the top-level entityFields if present
247
+ const entityFields = entityFieldsMap[serviceUrl] ?? rest.entityFields ?? {};
248
+ results.push(await registerServiceTool({ ...rest, serviceUrl, entityFields }));
249
+ }
250
+ return results;
251
+ }
252
+
253
+ module.exports = { register, registerServiceTool, registerAllServiceTools };
@@ -13,7 +13,7 @@
13
13
 
14
14
  /**
15
15
  * @param {import('@sap/cds')} cds
16
- * @param {{ serviceUrl?: string }} [options]
16
+ * @param {{ serviceUrl?: string, serviceList?: object[] }} [options]
17
17
  * @returns {{ title: string, content: string }[]}
18
18
  */
19
19
  function extract(cds, options = {}) {
@@ -22,16 +22,39 @@ function extract(cds, options = {}) {
22
22
 
23
23
  const docs = [];
24
24
  const serviceUrl = options.serviceUrl ?? "";
25
+ const serviceList = options.serviceList ?? [];
25
26
  const defs = model.definitions ?? {};
26
27
 
28
+
29
+ const entityServicePath = {};
30
+ for (const srv of serviceList) {
31
+ const srvPath = srv.path ?? srv.options?.path ?? "";
32
+ if (!srvPath) continue;
33
+ for (const entityName of Object.keys(srv.entities ?? {})) {
34
+ const shortName = entityName.split(".").pop();
35
+ if (!entityServicePath[shortName]) {
36
+ entityServicePath[shortName] = srvPath;
37
+ }
38
+ }
39
+ }
40
+
27
41
  // ── Collect entity definitions (skip SAP framework entities, deduplicate by short name) ──
42
+ // External SAP API imports (.csn / .edmx) add 300+ irrelevant docs and slow
43
+ // down registration. Filter by source file: only keep entities whose definition
44
+ // originates from a .cds file (the app's own code). Entities loaded from
45
+ // .csn or .edmx files are external service proxies and are skipped.
28
46
  const seenShortNames = new Set();
29
47
  const entityDefs = Object.entries(defs).filter(([fqName, d]) => {
30
48
  if (d.kind !== "entity") return false;
31
- // Skip SAP common / framework entities
49
+ // Skip SAP framework namespaces regardless of source file
32
50
  if (fqName.startsWith("sap.common.") || fqName.startsWith("cds.")) return false;
33
- // Deduplicate: keep only the first occurrence of each short entity name
51
+ // Skip entities whose source file is an external import (.csn / .edmx)
52
+ const srcFile = d.$location?.file ?? d["@src"] ?? "";
53
+ if (srcFile && !/\.cds$/i.test(srcFile)) return false;
34
54
  const shortName = fqName.split(".").pop();
55
+ // Skip localisation text shadow-entities (*_texts)
56
+ if (shortName.endsWith("_texts")) return false;
57
+ // Deduplicate: keep only the first occurrence of each short entity name
35
58
  if (seenShortNames.has(shortName)) return false;
36
59
  seenShortNames.add(shortName);
37
60
  return true;
@@ -41,6 +64,8 @@ function extract(cds, options = {}) {
41
64
  const shortName = fqName.split(".").pop();
42
65
  const elements = def.elements ?? {};
43
66
 
67
+ const entityPath = entityServicePath[shortName] ?? serviceUrl ?? "";
68
+
44
69
  const keys = [];
45
70
  const fields = [];
46
71
  const associations = [];
@@ -55,17 +80,32 @@ function extract(cds, options = {}) {
55
80
  if (el.type === "cds.Composition") {
56
81
  const target = _shortName(el.target ?? "");
57
82
  const card = el.cardinality?.max === "*" ? "many" : "one";
58
- compositions.push(`${elName} ${target} (composition of ${card})`);
83
+ const targetPath = entityServicePath[target] ?? entityPath;
84
+ // The FK on the child entity is named to_<ParentEntity>_<keyField>.
85
+ // Use the first key of the CURRENT entity as the parent key field.
86
+ const parentKeyField = keys.length > 0
87
+ ? `to_${shortName}_${keys[0].split(" ")[0]}`
88
+ : null;
89
+ const queryHint =
90
+ card === "many" && targetPath && parentKeyField
91
+ ? ` — query children: GET ${targetPath}/${target}?$filter=${parentKeyField}%20eq%20<value>`
92
+ : "";
93
+ compositions.push(`${elName} → ${target} (composition of ${card}${queryHint})`);
59
94
  } else if (el.type === "cds.Association") {
60
95
  const target = _shortName(el.target ?? "");
61
96
  const card = el.cardinality?.max === "*" ? "many" : "one";
62
- const fk = el.keys
97
+ const fkKeys = el.keys
63
98
  ?.map((k) => k.ref?.join(".") ?? "")
64
- .filter(Boolean)
65
- .join(", ");
99
+ .filter(Boolean) ?? [];
100
+ const fk = fkKeys.join(", ");
66
101
  const fkStr = fk ? `, FK: ${fk}` : "";
102
+ const targetPath = entityServicePath[target] ?? entityPath;
103
+ const filterHint =
104
+ fkKeys.length > 0 && targetPath
105
+ ? ` — to get related ${target} records: GET ${targetPath}/${target}?$filter=${fkKeys[0]} eq <value>`
106
+ : "";
67
107
  associations.push(
68
- `${elName} → ${target} (association to ${card}${fkStr})`,
108
+ `${elName} → ${target} (association to ${card}${fkStr}${filterHint})`,
69
109
  );
70
110
  } else if (el.key) {
71
111
  keys.push(`${elName} (${typeName}, key)`);
@@ -86,15 +126,74 @@ function extract(cds, options = {}) {
86
126
 
87
127
  const lines = [`Entity: ${shortName}`, `Fully qualified name: ${fqName}`];
88
128
 
89
- if (serviceUrl) {
90
- lines.push(`OData path: ${serviceUrl}/${shortName}`);
91
- lines.push(`Count records: GET ${serviceUrl}/${shortName}/$count`);
92
- lines.push(
93
- `Filter example: GET ${serviceUrl}/${shortName}?$filter=ID eq 1`,
94
- );
95
- lines.push(
96
- `Expand example: GET ${serviceUrl}/${shortName}?$expand=${associations[0]?.split(" ")[0] ?? "navProp"}`,
97
- );
129
+ // ── Build FK filter guidance FIRST so it appears at the top of the doc ──
130
+ // The agent retrieves the first part of the schema doc from the vector store.
131
+ // If filter guidance is at the bottom it gets truncated/ignored, causing the
132
+ // agent to query a parent entity (e.g. SalesOrder) instead of the child
133
+ // (e.g. SalesOrderItem). Putting guidance first ensures the agent reads it.
134
+ const fkFilters = [];
135
+ // 1. Association-derived FK guidance: walk each association and find its FK keys.
136
+ // NOTE: el.keys gives the TARGET entity's PK names, not the FK field on THIS
137
+ // entity. For unmanaged associations (defined with `on` clauses) or when the FK
138
+ // is an explicit key field (common in SAP CAP patterns), this loop may produce
139
+ // nothing — which is why key-field guidance (step 2) is also needed.
140
+ for (const [, el] of Object.entries(elements)) {
141
+ if (el.type !== "cds.Association") continue;
142
+ const fkKeys = el.keys?.map((k) => k.ref?.join(".") ?? "").filter(Boolean) ?? [];
143
+ const target = _shortName(el.target ?? "");
144
+ for (const fkField of fkKeys) {
145
+ // Detect integer FK fields — OData integer filters must NOT use quotes.
146
+ const fkEl = elements[fkField];
147
+ const isInt = fkEl?.type === "cds.Integer" || fkEl?.type === "cds.Int64"
148
+ || fkEl?.type === "cds.Int32" || fkEl?.type === "cds.Int16";
149
+ const valueTemplate = isInt ? "<numericValue>" : "'<stringValue>'";
150
+ if (entityPath) {
151
+ fkFilters.push(
152
+ `To get ${shortName} records for a specific ${target}: GET ${entityPath}/${shortName}?$filter=${fkField}%20eq%20${valueTemplate}`,
153
+ );
154
+ } else {
155
+ fkFilters.push(`To filter ${shortName} by ${target}: $filter=${fkField}%20eq%20${valueTemplate}`);
156
+ }
157
+ }
158
+ }
159
+
160
+ // 2. Key-field filter guidance: for entities with COMPOSITE KEYS (e.g. SalesOrderItem
161
+ // with keys salesOrderID + itemNumber), generate a filter example for each key.
162
+ // This is critical for entities whose FK is an explicit key field rather than a
163
+ // CDS managed association FK — without this the agent gets NO filter guidance and
164
+ // falls back to querying a wrong entity (e.g. SalesOrder instead of SalesOrderItem).
165
+ const keyElements = Object.entries(elements).filter(
166
+ ([name, el]) =>
167
+ el.key && !el.virtual && !el.target &&
168
+ !name.startsWith("_") &&
169
+ el.type !== "cds.Association" && el.type !== "cds.Composition",
170
+ );
171
+ if (keyElements.length > 1 && entityPath) {
172
+ for (const [name, el] of keyElements) {
173
+ const isInt = [
174
+ "cds.Integer", "cds.Int64", "cds.Int32", "cds.Int16",
175
+ "cds.Double", "cds.Decimal",
176
+ ].includes(el.type ?? "");
177
+ const valueTemplate = isInt ? "<numericValue>" : "'<value>'";
178
+ // Only add if not already covered by association FK guidance above
179
+ if (!fkFilters.some((f) => f.includes(`${name}%20eq`))) {
180
+ fkFilters.push(
181
+ `To filter ${shortName} by ${name}: GET ${entityPath}/${shortName}?$filter=${name}%20eq%20${valueTemplate}`,
182
+ );
183
+ }
184
+ }
185
+ }
186
+
187
+ if (fkFilters.length) {
188
+ lines.push(`*** QUERY GUIDANCE — use ${shortName} (NOT a parent entity) to find these records ***`);
189
+ lines.push(`Filter guidance (spaces MUST be %20, never +):`);
190
+ for (const f of fkFilters) lines.push(` ${f}`);
191
+ }
192
+
193
+ if (entityPath) {
194
+ lines.push(`OData path: ${entityPath}/${shortName}`);
195
+ lines.push(`Count records: GET ${entityPath}/${shortName}/$count`);
196
+ lines.push(`IMPORTANT: In OData filter URLs always encode spaces as %20, never as + (+ causes a parse error).`);
98
197
  }
99
198
 
100
199
  if (keys.length) lines.push(`Key fields: ${keys.join(", ")}`);
@@ -138,21 +237,33 @@ function extract(cds, options = {}) {
138
237
  });
139
238
  }
140
239
 
141
- // ── Services ────────────────────────────────────────────────────────────────
240
+ // ── Actions and Functions ──────────────────────────────────────────────────
241
+ const actionDocs = _extractActionsAndFunctions(defs);
242
+ docs.push(...actionDocs);
243
+
244
+ // ── Service definitions with correct OData paths ──────────────────────────
245
+ // Emit one entry per service showing its actual OData path (from runtime
246
+ // service list if available, otherwise derive from the model definition name).
142
247
  const serviceEntries = [];
143
248
  for (const [fqName, def] of Object.entries(defs)) {
144
249
  if (def.kind !== "service") continue;
145
250
  const svcName = fqName.split(".").pop();
146
251
  const exposed = Object.keys(def.elements ?? {});
252
+ // Look up the real runtime path from the service list
253
+ const runtimeSrv = serviceList.find(
254
+ (s) => (s.name ?? "").endsWith(svcName),
255
+ );
256
+ const svcPath = runtimeSrv?.path ?? runtimeSrv?.options?.path ?? "";
257
+ const pathNote = svcPath ? ` (OData path: ${svcPath})` : "";
147
258
  if (exposed.length) {
148
- serviceEntries.push(`${svcName}: ${exposed.join(", ")}`);
259
+ serviceEntries.push(`${svcName}${pathNote}: ${exposed.join(", ")}`);
149
260
  }
150
261
  }
151
262
  if (serviceEntries.length) {
152
263
  docs.push({
153
264
  title: "Service definitions",
154
265
  content: [
155
- "CAP services and the entities/projections they expose:",
266
+ "CAP services, their OData paths, and the entities/projections they expose:",
156
267
  ...serviceEntries,
157
268
  ].join("\n"),
158
269
  });
@@ -164,30 +275,36 @@ function extract(cds, options = {}) {
164
275
  const summaryLines = [
165
276
  `This CAP application defines ${entityNames.length} entities: ${entityNames.join(", ")}.`,
166
277
  ];
167
- if (serviceUrl) {
168
- summaryLines.push(`OData service base URL: ${serviceUrl}`);
169
- summaryLines.push("Query patterns:");
170
- summaryLines.push(" GET {serviceUrl}/{Entity} — list all records");
171
- summaryLines.push(
172
- " GET {serviceUrl}/{Entity}/{key} — single record by key",
173
- );
174
- summaryLines.push(" GET {serviceUrl}/{Entity}/$count — total count");
175
- summaryLines.push(
176
- " GET {serviceUrl}/{Entity}?$filter=field eq value — filter",
177
- );
178
- summaryLines.push(
179
- " GET {serviceUrl}/{Entity}?$select=f1,f2 — specific fields",
180
- );
181
- summaryLines.push(
182
- " GET {serviceUrl}/{Entity}?$top=10&$skip=0 — pagination",
183
- );
184
- summaryLines.push(
185
- " GET {serviceUrl}/{Entity}?$orderby=field desc — sorting",
186
- );
278
+ // Use the entity-to-service map to show each entity's real path
279
+ const uniquePaths = [...new Set(Object.values(entityServicePath))];
280
+ if (uniquePaths.length > 0) {
187
281
  summaryLines.push(
188
- " GET {serviceUrl}/{Entity}?$expand=navProp related entities",
282
+ `OData service paths: ${uniquePaths.join(", ")}`,
189
283
  );
284
+ } else if (serviceUrl) {
285
+ summaryLines.push(`OData service base URL: ${serviceUrl}`);
190
286
  }
287
+ summaryLines.push("Query patterns (replace {servicePath} and {Entity} with values from 'OData path' in each entity's schema doc):");
288
+ summaryLines.push(" GET {servicePath}/{Entity} — list all records");
289
+ summaryLines.push(
290
+ " GET {servicePath}/{Entity}/{key} — single record by key",
291
+ );
292
+ summaryLines.push(" GET {servicePath}/{Entity}/$count — total count");
293
+ summaryLines.push(
294
+ " GET {servicePath}/{Entity}?$filter=field eq value — filter",
295
+ );
296
+ summaryLines.push(
297
+ " GET {servicePath}/{Entity}?$select=f1,f2 — specific fields",
298
+ );
299
+ summaryLines.push(
300
+ " GET {servicePath}/{Entity}?$top=10&$skip=0 — pagination",
301
+ );
302
+ summaryLines.push(
303
+ " GET {servicePath}/{Entity}?$orderby=field desc — sorting",
304
+ );
305
+ summaryLines.push(
306
+ " GET {servicePath}/{Entity}?$expand=navProp — related entities",
307
+ );
191
308
  docs.push({
192
309
  title: "Application schema summary",
193
310
  content: summaryLines.join("\n"),
@@ -263,4 +380,68 @@ function _shortName(fqn) {
263
380
  return fqn.split(".").pop() ?? fqn;
264
381
  }
265
382
 
383
+ function _returnTypeStr(ret) {
384
+ if (!ret) return null;
385
+ if (ret.items) {
386
+ const inner = ret.items.type ?? ret.items.ref?.[0] ?? "unknown";
387
+ return `Array of ${_shortName(inner)}`;
388
+ }
389
+ return _shortType(ret.type ?? "");
390
+ }
391
+
392
+ function _paramListStr(params) {
393
+ return Object.entries(params ?? {})
394
+ .map(([name, p]) => `${name}: ${_shortType(p.type ?? "")}`)
395
+ .join(", ");
396
+ }
397
+
398
+ /** Extract unbound CDS actions and functions. */
399
+ function _extractUnbound(defs) {
400
+ const docs = [];
401
+ for (const [fqName, def] of Object.entries(defs)) {
402
+ if (def.kind !== "action" && def.kind !== "function") continue;
403
+ const shortName = fqName.split(".").pop();
404
+ const kind = def.kind === "action" ? "Action" : "Function";
405
+ const lines = [`${kind}: ${shortName}`, `Fully qualified name: ${fqName}`];
406
+ const params = _paramListStr(def.params);
407
+ if (params) lines.push(`Parameters: ${params}`);
408
+ const ret = _returnTypeStr(def.returns);
409
+ if (ret) lines.push(`Returns: ${ret}`);
410
+ if (def["@description"]) lines.push(`Description: ${def["@description"]}`);
411
+ if (def["@requires"]) lines.push(`Requires role: ${def["@requires"]}`);
412
+ docs.push({ title: `${kind}: ${shortName}`, content: lines.join("\n") });
413
+ }
414
+ return docs;
415
+ }
416
+
417
+ /** Extract bound actions/functions declared as service-level elements. */
418
+ function _extractBound(defs) {
419
+ const docs = [];
420
+ for (const [fqName, def] of Object.entries(defs)) {
421
+ if (def.kind !== "service") continue;
422
+ const svcName = fqName.split(".").pop();
423
+ for (const [elName, el] of Object.entries(def.elements ?? {})) {
424
+ if (el.kind !== "action" && el.kind !== "function") continue;
425
+ const kind = el.kind === "action" ? "Bound Action" : "Bound Function";
426
+ const lines = [
427
+ `${kind}: ${elName}`,
428
+ `Service: ${svcName}`,
429
+ `Call path: POST ${svcName}/${elName}`,
430
+ ];
431
+ const params = _paramListStr(el.params);
432
+ if (params) lines.push(`Parameters: ${params}`);
433
+ docs.push({ title: `${kind}: ${elName}`, content: lines.join("\n") });
434
+ }
435
+ }
436
+ return docs;
437
+ }
438
+
439
+ /**
440
+ * Extract unbound and bound CDS actions/functions from the model definitions.
441
+ * These describe what operations the service exposes beyond plain CRUD.
442
+ */
443
+ function _extractActionsAndFunctions(defs) {
444
+ return [..._extractUnbound(defs), ..._extractBound(defs)];
445
+ }
446
+
266
447
  module.exports = { extract, extractFromServices };