cap-copilot-sdk 0.1.1 → 0.2.3
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 +93 -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/index.js
CHANGED
|
@@ -1,41 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* @btp-copilot/cap-plugin — CDS plugin entry point.
|
|
4
|
-
*
|
|
5
|
-
* On CAP startup this plugin:
|
|
6
|
-
* 1. Reads the CDS compiled model (entity schemas, types, relationships)
|
|
7
|
-
* 2. Reads the project folder structure (srv handlers, db schema files,
|
|
8
|
-
* Fiori XML views, manifests, i18n labels, annotations)
|
|
9
|
-
* 3. Iterates every running CDS service's srv.entities for runtime info
|
|
10
|
-
* 4. POSTs all collected documents to the BTP Copilot backend so that the
|
|
11
|
-
* chatbot can answer context-aware questions about this app
|
|
12
|
-
*
|
|
13
|
-
* Minimal configuration in your app's package.json:
|
|
14
|
-
* {
|
|
15
|
-
* "cds": {
|
|
16
|
-
* "requires": {
|
|
17
|
-
* "btp-copilot": {
|
|
18
|
-
* "kind": "btp-copilot",
|
|
19
|
-
* "backendUrl": "https://your-copilot.cfapps.eu10.hana.ondemand.com",
|
|
20
|
-
* "appId": "my-sales-app",
|
|
21
|
-
* "appName": "My Sales Application",
|
|
22
|
-
* "serviceUrl": "/odata/v4/SalesService",
|
|
23
|
-
* "token": "<bearer-token-or-read-from-env>"
|
|
24
|
-
* }
|
|
25
|
-
* },
|
|
26
|
-
* "plugins": ["@btp-copilot/cap-plugin"]
|
|
27
|
-
* }
|
|
28
|
-
* }
|
|
29
|
-
*
|
|
30
|
-
* Or programmatically (in a custom server.js):
|
|
31
|
-
* const btpCopilot = require('@btp-copilot/cap-plugin');
|
|
32
|
-
* btpCopilot({ backendUrl: '...', appId: '...' });
|
|
33
|
-
*/
|
|
34
2
|
|
|
35
3
|
const path = require("path");
|
|
36
4
|
const { extract, extractFromServices } = require("./SchemaExtractor");
|
|
37
5
|
const { scan } = require("./ProjectScanner");
|
|
38
|
-
const { register } = require("./Registrar");
|
|
6
|
+
const { register, registerAllServiceTools } = require("./Registrar");
|
|
7
|
+
const { sendEntityData } = require("./DataSender");
|
|
8
|
+
|
|
9
|
+
function _detectAppBaseUrl() {
|
|
10
|
+
const vcap = process.env.VCAP_APPLICATION;
|
|
11
|
+
if (vcap) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(vcap);
|
|
14
|
+
const uris = parsed.application_uris ?? parsed.uris ?? [];
|
|
15
|
+
if (uris.length > 0) return `https://${uris[0]}`;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.warn("Failed to parse VCAP_APPLICATION:", err.message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const k8sUrl = process.env.APPLICATION_URL ?? process.env.APP_URL;
|
|
22
|
+
if (k8sUrl) return k8sUrl;
|
|
23
|
+
|
|
24
|
+
// ── Local development ──────────────────────────────────────────────────────
|
|
25
|
+
const port = process.env.PORT ?? 4004;
|
|
26
|
+
return `http://localhost:${port}`;
|
|
27
|
+
}
|
|
39
28
|
|
|
40
29
|
module.exports = (options = {}) => {
|
|
41
30
|
const cds = require("@sap/cds");
|
|
@@ -49,26 +38,28 @@ module.exports = (options = {}) => {
|
|
|
49
38
|
);
|
|
50
39
|
|
|
51
40
|
const {
|
|
52
|
-
backendUrl = process.env.BTP_COPILOT_URL,
|
|
41
|
+
backendUrl = process.env.BTP_COPILOT_URL ?? "http://localhost:8000",
|
|
53
42
|
appId = process.env.BTP_COPILOT_APP_ID,
|
|
54
43
|
appName,
|
|
55
44
|
serviceUrl,
|
|
45
|
+
serviceUrls,
|
|
46
|
+
appBaseUrl = process.env.BTP_COPILOT_APP_BASE_URL ?? _detectAppBaseUrl(),
|
|
56
47
|
token = process.env.BTP_COPILOT_TOKEN,
|
|
57
48
|
includeProjectFiles = true,
|
|
58
49
|
includeHandlers = true,
|
|
59
50
|
includeViews = true,
|
|
51
|
+
// verboseLogging: when true, CAP logs print the FULL JSON of every record
|
|
52
|
+
// pushed to the chatbot (instead of a 300-char truncated sample).
|
|
53
|
+
// Enable temporarily when debugging why the chatbot has wrong/missing data.
|
|
54
|
+
// WARNING: can produce very large console output for entities with wide rows.
|
|
55
|
+
verboseLogging = process.env.BTP_COPILOT_VERBOSE === "true" ? true : false,
|
|
60
56
|
} = cfg;
|
|
61
57
|
|
|
62
58
|
if (!backendUrl || !appId) {
|
|
63
|
-
log.warn(
|
|
64
|
-
"Missing required config (backendUrl, appId). " +
|
|
65
|
-
"Set them in cds.requires.btp-copilot or via BTP_COPILOT_URL / BTP_COPILOT_APP_ID env vars. " +
|
|
66
|
-
"Skipping registration.",
|
|
67
|
-
);
|
|
59
|
+
log.warn("Missing required config (backendUrl, appId).");
|
|
68
60
|
return;
|
|
69
61
|
}
|
|
70
62
|
|
|
71
|
-
// Validate appId format (backend requires ^[a-zA-Z0-9_-]+$)
|
|
72
63
|
if (!/^[a-zA-Z0-9_-]+$/.test(appId)) {
|
|
73
64
|
log.warn(
|
|
74
65
|
`appId "${appId}" contains invalid characters. Use only letters, digits, underscores and hyphens.`,
|
|
@@ -81,12 +72,10 @@ module.exports = (options = {}) => {
|
|
|
81
72
|
log.info(`Collecting context for app "${appId}"…`);
|
|
82
73
|
const allDocs = [];
|
|
83
74
|
|
|
84
|
-
|
|
85
|
-
const schemaDocs = extract(cds, { serviceUrl });
|
|
75
|
+
const schemaDocs = extract(cds, { serviceUrl, serviceList });
|
|
86
76
|
allDocs.push(...schemaDocs);
|
|
87
77
|
log.info(` + ${schemaDocs.length} schema documents`);
|
|
88
78
|
|
|
89
|
-
// ── 2. Runtime srv.entities for each running service ────────────────────
|
|
90
79
|
const serviceList = Array.isArray(services)
|
|
91
80
|
? services
|
|
92
81
|
: Object.values(services ?? {});
|
|
@@ -97,7 +86,6 @@ module.exports = (options = {}) => {
|
|
|
97
86
|
log.info(` + ${runtimeDocs.length} runtime service documents`);
|
|
98
87
|
}
|
|
99
88
|
|
|
100
|
-
// ── 3. Project folder structure, src files, manifests ──────────────────
|
|
101
89
|
if (includeProjectFiles) {
|
|
102
90
|
const projectRoot = cds.env.root ?? process.cwd();
|
|
103
91
|
const fileDocs = scan(projectRoot, { includeHandlers, includeViews });
|
|
@@ -111,14 +99,19 @@ module.exports = (options = {}) => {
|
|
|
111
99
|
}
|
|
112
100
|
|
|
113
101
|
log.info(
|
|
114
|
-
`Registering ${
|
|
102
|
+
`Registering ${allDocs.length} schema/project document(s) for app "${appId}"…`,
|
|
115
103
|
);
|
|
116
104
|
|
|
105
|
+
// ── Schema + project docs: backend may skip if unchanged ────────────────
|
|
117
106
|
const ok = await register({
|
|
118
107
|
backendUrl,
|
|
119
108
|
appId,
|
|
120
109
|
appName: appName ?? appId,
|
|
121
110
|
serviceUrl,
|
|
111
|
+
// Persist base URL in main app registry so the backend can resolve live
|
|
112
|
+
// OData queries even after it restarts (service-tool registrations are
|
|
113
|
+
// in-memory and lost on restart; the app registry persists).
|
|
114
|
+
appBaseUrl,
|
|
122
115
|
documents: allDocs,
|
|
123
116
|
token,
|
|
124
117
|
});
|
|
@@ -132,8 +125,260 @@ module.exports = (options = {}) => {
|
|
|
132
125
|
`✘ Registration failed for "${appId}". Check backendUrl (${backendUrl}) and token.`,
|
|
133
126
|
);
|
|
134
127
|
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
const countDoc = await _snapshotEntityCounts(serviceList, cds);
|
|
131
|
+
const sampleDocs = await _snapshotEntitySamples(serviceList, cds);
|
|
132
|
+
const dataDocs = [];
|
|
133
|
+
if (countDoc) dataDocs.push(countDoc);
|
|
134
|
+
dataDocs.push(...sampleDocs);
|
|
135
|
+
|
|
136
|
+
if (dataDocs.length > 0) {
|
|
137
|
+
log.info(
|
|
138
|
+
`Pushing ${dataDocs.length} data snapshot document(s) (bypasses schema cache)…`,
|
|
139
|
+
);
|
|
140
|
+
await register({
|
|
141
|
+
backendUrl,
|
|
142
|
+
appId,
|
|
143
|
+
appName: appName ?? appId,
|
|
144
|
+
documents: dataDocs,
|
|
145
|
+
token,
|
|
146
|
+
replace: false,
|
|
147
|
+
});
|
|
148
|
+
log.info(` + ${dataDocs.length} data snapshot document(s) indexed.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── OData service tool registration ─────────────────────────────────────
|
|
152
|
+
// Register each running service as a separate tool so the backend maps
|
|
153
|
+
// entity names to the correct OData path unambiguously.
|
|
154
|
+
// When no explicit serviceUrls config is given, auto-register ALL running
|
|
155
|
+
// services (each with its own entity list, never merged).
|
|
156
|
+
if (appBaseUrl) {
|
|
157
|
+
const liveQueryUrls = serviceUrls ?? (serviceUrl ? [serviceUrl] : []);
|
|
158
|
+
|
|
159
|
+
// Build { serviceUrl, entities, entityFields } per service.
|
|
160
|
+
// entityFields is a map of entityName → [fieldName, ...] so the backend
|
|
161
|
+
// agent can build OData $filter clauses without parsing RAG text.
|
|
162
|
+
const _buildSvcEntry = (srv) => {
|
|
163
|
+
const svcUrl = srv.path ?? srv.options?.path ?? "";
|
|
164
|
+
const entityMap = srv.entities ?? {};
|
|
165
|
+
const entities = Object.keys(entityMap).filter((n) => !n.startsWith("_"));
|
|
166
|
+
const entityFields = {};
|
|
167
|
+
for (const [eName, eDef] of Object.entries(entityMap)) {
|
|
168
|
+
if (eName.startsWith("_") || !eDef) continue;
|
|
169
|
+
entityFields[eName] = Object.keys(eDef.elements ?? {}).filter(
|
|
170
|
+
(k) => !k.startsWith("_"),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return { serviceUrl: svcUrl, entities, entityFields };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Build per-service registrations: use explicit list when configured,
|
|
177
|
+
// otherwise auto-discover every service that has entities.
|
|
178
|
+
const toRegister =
|
|
179
|
+
liveQueryUrls.length > 0
|
|
180
|
+
? // Explicit list: match running services by path
|
|
181
|
+
serviceList
|
|
182
|
+
.filter((srv) => {
|
|
183
|
+
const srvPath = srv.path ?? srv.options?.path ?? "";
|
|
184
|
+
return liveQueryUrls.some(
|
|
185
|
+
(u) =>
|
|
186
|
+
u === srvPath ||
|
|
187
|
+
srvPath.endsWith(u) ||
|
|
188
|
+
u.endsWith(srvPath),
|
|
189
|
+
);
|
|
190
|
+
})
|
|
191
|
+
.map(_buildSvcEntry)
|
|
192
|
+
.filter((s) => s.serviceUrl)
|
|
193
|
+
: // Auto-discover: every service with a path and at least one entity
|
|
194
|
+
serviceList
|
|
195
|
+
.map(_buildSvcEntry)
|
|
196
|
+
.filter((s) => s.serviceUrl && s.entities.length > 0);
|
|
197
|
+
|
|
198
|
+
if (toRegister.length > 0) {
|
|
199
|
+
log.info(
|
|
200
|
+
`Auto-discovered ${toRegister.length} OData service(s) for live-query tool registration.`,
|
|
201
|
+
);
|
|
202
|
+
for (const { serviceUrl: sUrl, entities, entityFields } of toRegister) {
|
|
203
|
+
await registerAllServiceTools({
|
|
204
|
+
backendUrl,
|
|
205
|
+
appId,
|
|
206
|
+
appName: appName ?? appId,
|
|
207
|
+
serviceUrls: [sUrl],
|
|
208
|
+
entities,
|
|
209
|
+
// Pass per-service entity→fields map so the backend agent can build
|
|
210
|
+
// OData $filter clauses without parsing RAG text
|
|
211
|
+
entityFieldsMap: { [sUrl]: entityFields ?? {} },
|
|
212
|
+
token,
|
|
213
|
+
appBaseUrl,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
log.info(
|
|
217
|
+
` + OData live-query tools registered for ${toRegister.length} service(s)`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
log.info(
|
|
222
|
+
" ℹ Live OData query tool not registered — appBaseUrl could not be determined." +
|
|
223
|
+
" Set BTP_COPILOT_APP_BASE_URL or deploy to CF/Kyma for auto-detection.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const dataCfg = { backendUrl, appId, appName: appName ?? appId, token, verboseLogging };
|
|
228
|
+
_registerLiveDataHooks(serviceList, dataCfg, log);
|
|
135
229
|
} catch (err) {
|
|
136
230
|
log.error("Registration error —", err.message);
|
|
137
231
|
}
|
|
138
232
|
});
|
|
139
233
|
};
|
|
234
|
+
|
|
235
|
+
const PAGE_SIZE = 50;
|
|
236
|
+
|
|
237
|
+
const MAX_PAGES = 10;
|
|
238
|
+
|
|
239
|
+
async function _snapshotEntitySamples(services, cds) {
|
|
240
|
+
const { SELECT } = cds;
|
|
241
|
+
const docs = [];
|
|
242
|
+
|
|
243
|
+
for (const srv of services) {
|
|
244
|
+
if (!srv?.entities) continue;
|
|
245
|
+
for (const [name, entity] of Object.entries(srv.entities)) {
|
|
246
|
+
if (!entity || name.startsWith("_")) continue;
|
|
247
|
+
try {
|
|
248
|
+
const hasModifiedAt =
|
|
249
|
+
entity.elements && "modifiedAt" in entity.elements;
|
|
250
|
+
|
|
251
|
+
let totalCount = null;
|
|
252
|
+
try {
|
|
253
|
+
const cr = await cds.run(
|
|
254
|
+
SELECT.one.from(entity).columns("count(*) as n"),
|
|
255
|
+
);
|
|
256
|
+
totalCount = Number(cr?.n ?? 0);
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let page = 0;
|
|
261
|
+
|
|
262
|
+
while (page < MAX_PAGES) {
|
|
263
|
+
const query = hasModifiedAt
|
|
264
|
+
? SELECT.from(entity)
|
|
265
|
+
.orderBy("modifiedAt desc")
|
|
266
|
+
.limit(PAGE_SIZE, page * PAGE_SIZE)
|
|
267
|
+
: SELECT.from(entity).limit(PAGE_SIZE, page * PAGE_SIZE);
|
|
268
|
+
|
|
269
|
+
const rows = await cds.run(query);
|
|
270
|
+
if (!rows || rows.length === 0) break;
|
|
271
|
+
|
|
272
|
+
const fromRec = page * PAGE_SIZE + 1;
|
|
273
|
+
const toRec = page * PAGE_SIZE + rows.length;
|
|
274
|
+
const coverageNote =
|
|
275
|
+
totalCount !== null
|
|
276
|
+
? totalCount <= toRec
|
|
277
|
+
? `COMPLETE dataset — all ${totalCount} record(s) are shown below`
|
|
278
|
+
: `PARTIAL snapshot — showing records ${fromRec}–${toRec} of ${totalCount} total. ` +
|
|
279
|
+
`Records ${toRec + 1}–${totalCount} are NOT in this document. ` +
|
|
280
|
+
`Do NOT say there are no records for an ID just because it is absent here; ` +
|
|
281
|
+
`use the live OData query tool or ask the user to navigate to that record.`
|
|
282
|
+
: `Records ${fromRec}–${toRec} (total count unknown)`;
|
|
283
|
+
|
|
284
|
+
const docTitle =
|
|
285
|
+
page === 0
|
|
286
|
+
? `Entity data: ${name}`
|
|
287
|
+
: `Entity data: ${name} — page ${page + 1}`;
|
|
288
|
+
|
|
289
|
+
const content = [
|
|
290
|
+
`Entity: ${name}`,
|
|
291
|
+
`Coverage: ${coverageNote}`,
|
|
292
|
+
`Fetched at: ${new Date().toISOString()}`,
|
|
293
|
+
"",
|
|
294
|
+
"Records:",
|
|
295
|
+
JSON.stringify(rows, null, 2),
|
|
296
|
+
].join("\n");
|
|
297
|
+
|
|
298
|
+
docs.push({ title: docTitle, content });
|
|
299
|
+
|
|
300
|
+
if (rows.length < PAGE_SIZE) break;
|
|
301
|
+
page++;
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const reason = err?.message ?? "read failed";
|
|
305
|
+
docs.push({
|
|
306
|
+
title: `Entity data: ${name}`,
|
|
307
|
+
content: [
|
|
308
|
+
`Entity: ${name}`,
|
|
309
|
+
`Status: UNAVAILABLE`,
|
|
310
|
+
`Reason: ${reason}`,
|
|
311
|
+
"Note: This entity's read handler may depend on an external service",
|
|
312
|
+
"that is currently unreachable or has changed its API.",
|
|
313
|
+
].join("\n"),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return docs;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function _registerLiveDataHooks(services, cfg, log) {
|
|
323
|
+
for (const srv of services) {
|
|
324
|
+
if (!srv?.entities || typeof srv.after !== "function") continue;
|
|
325
|
+
for (const [name] of Object.entries(srv.entities)) {
|
|
326
|
+
if (name.startsWith("_")) continue;
|
|
327
|
+
try {
|
|
328
|
+
srv.after("READ", name, async (data) => {
|
|
329
|
+
if (!data) return;
|
|
330
|
+
const records = Array.isArray(data) ? data : [data];
|
|
331
|
+
if (records.length === 0) return;
|
|
332
|
+
// $count filtering is handled inside sendEntityData
|
|
333
|
+
sendEntityData(cfg, name, records, `Entity data: ${name}`).catch(
|
|
334
|
+
() => {},
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
srv.after(["CREATE", "UPDATE"], name, async (data) => {
|
|
339
|
+
if (!data) return;
|
|
340
|
+
const records = Array.isArray(data) ? data : [data];
|
|
341
|
+
if (records.length === 0) return;
|
|
342
|
+
sendEntityData(
|
|
343
|
+
cfg,
|
|
344
|
+
name,
|
|
345
|
+
records,
|
|
346
|
+
`Entity data: ${name} — mutations`,
|
|
347
|
+
).catch(() => {});
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
log.warn(
|
|
351
|
+
`Could not register live-data hook for ${name}: ${err.message}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function _snapshotEntityCounts(services, cds) {
|
|
359
|
+
const { SELECT } = cds;
|
|
360
|
+
const lines = ["Entity record counts (at application startup):"];
|
|
361
|
+
let hasAny = false;
|
|
362
|
+
|
|
363
|
+
for (const srv of services) {
|
|
364
|
+
if (!srv?.entities) continue;
|
|
365
|
+
for (const [name, entity] of Object.entries(srv.entities)) {
|
|
366
|
+
if (!entity || name.startsWith("_")) continue;
|
|
367
|
+
try {
|
|
368
|
+
const result = await cds.run(
|
|
369
|
+
SELECT.one.from(entity).columns("count(*) as n"),
|
|
370
|
+
);
|
|
371
|
+
const n = result?.n ?? 0;
|
|
372
|
+
lines.push(` ${name}: ${Number(n).toLocaleString()} record(s)`);
|
|
373
|
+
hasAny = true;
|
|
374
|
+
} catch (err) {
|
|
375
|
+
const reason = err?.message ?? "read failed";
|
|
376
|
+
lines.push(` ${name}: UNAVAILABLE — ${reason}`);
|
|
377
|
+
hasAny = true;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!hasAny) return null;
|
|
383
|
+
return { title: "Live entity record counts", content: lines.join("\n") };
|
|
384
|
+
}
|