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/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
- // ── 1. CDS model schema (entities, relationships, services) ─────────────
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 ${Math.min(allDocs.length, 50)} documents (of ${allDocs.length} total) for app "${appId}"…`,
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
+ }