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/cds-plugin.js +547 -88
- package/package.json +12 -2
- package/postinstall.js +81 -0
- package/src/DataSender.js +469 -0
- package/src/ProjectScanner.js +17 -5
- package/src/Registrar.js +227 -48
- package/src/SchemaExtractor.js +222 -41
- package/src/index.js +290 -45
package/src/Registrar.js
CHANGED
|
@@ -1,74 +1,253 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const https = require("https");
|
|
3
|
-
const http
|
|
3
|
+
const http = require("http");
|
|
4
4
|
const { URL } = require("url");
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
43
|
-
path:
|
|
44
|
-
method:
|
|
135
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
136
|
+
path: url.pathname + url.search,
|
|
137
|
+
method: "POST",
|
|
45
138
|
headers,
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
51
|
-
res.on("data", (
|
|
146
|
+
let data = "";
|
|
147
|
+
res.on("data", (c) => (data += c));
|
|
52
148
|
res.on("end", () => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/SchemaExtractor.js
CHANGED
|
@@ -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
|
|
49
|
+
// Skip SAP framework namespaces regardless of source file
|
|
32
50
|
if (fqName.startsWith("sap.common.") || fqName.startsWith("cds.")) return false;
|
|
33
|
-
//
|
|
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
|
-
|
|
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
|
|
97
|
+
const fkKeys = el.keys
|
|
63
98
|
?.map((k) => k.ref?.join(".") ?? "")
|
|
64
|
-
.filter(Boolean)
|
|
65
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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 };
|