@tiledesk/tiledesk-server 2.29.0 → 3.0.0

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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Snapshot e impronta di un agente. Funzioni pure: nessun accesso al database.
3
+ *
4
+ * - Lo snapshot ha la forma dell'export (`GET /faq_kb/exportjson/:id`), senza `attributes.globals`.
5
+ * - L'impronta (`contentHash`) è lo sha256 dei blocchi normalizzati: ordinati per `intent_id`,
6
+ * senza posizione e colore, con le chiavi in ordine. Due agenti con la stessa impronta hanno
7
+ * gli stessi blocchi, a prescindere da come sono disposti sul canvas.
8
+ */
9
+ const crypto = require('crypto');
10
+ const zlib = require('zlib');
11
+
12
+ const ENCODING = 'gzip+json';
13
+
14
+ /** Campi del blocco che non entrano nell'export (come in exportjson). */
15
+ const INTENT_OMIT = ['_id', 'id', 'id_project', 'topic', 'status', 'chatbot_id', 'createdBy', 'createdAt', 'updatedAt', '__v', 'id_faq_kb', 'faq_kb'];
16
+
17
+ /** Campi dell'agente che entrano nello snapshot. */
18
+ const KB_FIELDS = ['name', 'description', 'language', 'webhook_enabled', 'webhook_url', 'type', 'subtype'];
19
+
20
+ function omit(obj, keys) {
21
+ const out = {};
22
+ Object.keys(obj || {}).forEach(key => {
23
+ if (keys.indexOf(key) === -1) out[key] = obj[key];
24
+ });
25
+ return out;
26
+ }
27
+
28
+ function clone(value) {
29
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
30
+ }
31
+
32
+ /** Un blocco come nell'export. */
33
+ function exportIntent(faq) {
34
+ return clone(omit(faq, INTENT_OMIT));
35
+ }
36
+
37
+ /** Gli attributes dell'agente senza i globals, che possono contenere segreti. */
38
+ function attributesWithoutGlobals(attributes) {
39
+ return omit(attributes || {}, ['globals']);
40
+ }
41
+
42
+ /** Lo snapshot di un agente, dai documenti `faq_kb` e `faq` (lean o toObject). */
43
+ function buildSnapshot(kb, faqs) {
44
+ const snapshot = {};
45
+ KB_FIELDS.forEach(field => {
46
+ if (kb[field] !== undefined) snapshot[field] = clone(kb[field]);
47
+ });
48
+ snapshot.attributes = clone(attributesWithoutGlobals(kb.attributes));
49
+ snapshot.intents = (faqs || []).map(exportIntent);
50
+ return snapshot;
51
+ }
52
+
53
+ /** JSON con le chiavi in ordine, a ogni livello: la stessa struttura dà sempre la stessa stringa. */
54
+ function stableStringify(value) {
55
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
56
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
57
+ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
58
+ return '{' + keys.map(key => JSON.stringify(key) + ':' + stableStringify(value[key])).join(',') + '}';
59
+ }
60
+
61
+ /** Il blocco senza ciò che riguarda solo il disegno sul canvas. */
62
+ function normalizeIntent(intent) {
63
+ const normalized = exportIntent(intent);
64
+ if (normalized.attributes && typeof normalized.attributes === 'object') {
65
+ normalized.attributes = omit(normalized.attributes, ['position', 'color']);
66
+ }
67
+ return normalized;
68
+ }
69
+
70
+ /** L'impronta dei blocchi. */
71
+ function hashIntents(intents) {
72
+ const normalized = (intents || [])
73
+ .map(normalizeIntent)
74
+ .sort((a, b) => String(a.intent_id).localeCompare(String(b.intent_id)));
75
+ return 'sha256:' + crypto.createHash('sha256').update(stableStringify(normalized)).digest('hex');
76
+ }
77
+
78
+ /** Lo snapshot compresso, pronto per il record. */
79
+ function compress(snapshot) {
80
+ const json = JSON.stringify(snapshot);
81
+ return {
82
+ encoding: ENCODING,
83
+ data: zlib.gzipSync(Buffer.from(json, 'utf8')),
84
+ bytes: Buffer.byteLength(json, 'utf8'),
85
+ intents: Array.isArray(snapshot.intents) ? snapshot.intents.length : 0
86
+ };
87
+ }
88
+
89
+ /** Lo snapshot decompresso da un record; null se il record non ne ha uno. */
90
+ function decompress(stored) {
91
+ if (!stored || !stored.data) return null;
92
+ const buffer = Buffer.isBuffer(stored.data) ? stored.data : Buffer.from(stored.data.buffer || stored.data);
93
+ if (stored.encoding && stored.encoding !== ENCODING) {
94
+ throw new Error('Unsupported snapshot encoding ' + stored.encoding);
95
+ }
96
+ return JSON.parse(zlib.gunzipSync(buffer).toString('utf8'));
97
+ }
98
+
99
+ module.exports = {
100
+ ENCODING,
101
+ buildSnapshot,
102
+ exportIntent,
103
+ attributesWithoutGlobals,
104
+ normalizeIntent,
105
+ hashIntents,
106
+ stableStringify,
107
+ compress,
108
+ decompress
109
+ };
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Test del modulo aiGenerations.
3
+ *
4
+ * Il modulo si accende con AI_GENERATIONS_ENABLED=true. Questo file la imposta prima di caricare
5
+ * l'app, quindi lanciato da solo (`npx mocha pubmodules/aiGenerations/test/*.js --exit`) prova
6
+ * tutte le rotte. Nella suite completa l'app è già caricata da altri file: se la variabile non era
7
+ * nell'ambiente, il modulo non è montato e qui si verifica solo che le rotte rispondano 404.
8
+ */
9
+ process.env.NODE_ENV = 'test';
10
+ if (process.env.AI_GENERATIONS_ENABLED === undefined) process.env.AI_GENERATIONS_ENABLED = 'true';
11
+ if (process.env.AI_GENERATIONS_CLEANUP_ENABLED === undefined) process.env.AI_GENERATIONS_CLEANUP_ENABLED = 'true';
12
+ if (process.env.AI_GENERATIONS_AUTOSNAPSHOT_ENABLED === undefined) process.env.AI_GENERATIONS_AUTOSNAPSHOT_ENABLED = 'true';
13
+ if (process.env.AI_GENERATIONS_AUTOSNAPSHOT_DEBOUNCE_MS === undefined) process.env.AI_GENERATIONS_AUTOSNAPSHOT_DEBOUNCE_MS = '60000';
14
+ if (process.env.AI_GENERATIONS_AUTOSNAPSHOT_RECENT_MS === undefined) process.env.AI_GENERATIONS_AUTOSNAPSHOT_RECENT_MS = '200';
15
+
16
+ const chai = require('chai');
17
+ const chaiHttp = require('chai-http');
18
+ const expect = chai.expect;
19
+ chai.use(chaiHttp);
20
+
21
+ const server = require('../../../app');
22
+ const pubModulesManager = require('../../pubModulesManager');
23
+ const userService = require('../../../services/userService');
24
+ const projectService = require('../../../services/projectService');
25
+ const Faq = require('../../../models/faq');
26
+ const Faq_kb = require('../../../models/faq_kb');
27
+ const FaqKbRevision = require('../models/faqKbRevision');
28
+ const snapshotUtil = require('../services/snapshot');
29
+ const botEvent = require('../../../event/botEvent');
30
+ const faqBotEvent = require('../../../event/faqBotEvent');
31
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
32
+ const listeners = require('../listeners');
33
+
34
+ const BASE = '/modules/ai-generations';
35
+ const mounted = !!pubModulesManager.aiGenerationsRoute;
36
+ // Lanciato da solo con la variabile accesa, il modulo DEVE essere montato: altrimenti e' fallito al caricamento.
37
+ if (process.env.AI_GENERATIONS_ENABLED === 'true' && !mounted && !process.env.AI_GENERATIONS_TEST_EXPECT_OFF) {
38
+ throw new Error('aiGenerations module is enabled but not mounted: check the server log for "error initializing aiGenerations module"');
39
+ }
40
+
41
+ function sampleAgent(name) {
42
+ const intent = (id, displayName, actions, next) => ({
43
+ intent_id: id,
44
+ intent_display_name: displayName,
45
+ enabled: true,
46
+ webhook_enabled: false,
47
+ language: 'it',
48
+ actions: actions,
49
+ attributes: { position: { x: 100, y: 100 }, color: 'blue', nextBlockAction: { _tdActionType: 'intent', intentName: next || '' }, connectors: {} }
50
+ });
51
+ return {
52
+ name: name || 'Prenotazione tavolo',
53
+ description: 'test',
54
+ language: 'it',
55
+ attributes: { variables: { people: 'people' } },
56
+ intents: [
57
+ Object.assign(intent('i-start', 'start', [{ _tdActionType: 'intent', intentName: '#i-welcome' }]), { question: '\\start' }),
58
+ intent('i-welcome', 'Benvenuto', [{ _tdActionType: 'reply', text: 'Ciao! Vuoi prenotare un tavolo?' }], '#i-people'),
59
+ intent('i-people', 'Quante persone', [{ _tdActionType: 'capture_user_reply', assignReplyTo: 'people', goToIntent: '#i-confirm' }]),
60
+ intent('i-confirm', 'Conferma', [{ _tdActionType: 'reply', text: 'Tavolo per {{people}} prenotato.' }]),
61
+ intent('i-fallback', 'Fallback', [{ _tdActionType: 'reply', text: 'Non ho capito.' }]),
62
+ Object.assign(intent('i-default', 'defaultFallback', []), { question: 'defaultFallback' })
63
+ ]
64
+ };
65
+ }
66
+
67
+ const sampleAi = {
68
+ request: { initialPrompt: 'un bot che prenota tavoli', finalPrompt: 'PROMPT FINALE', finalPromptEdited: false, interview: { questions: 3 } },
69
+ result: { blueprint: { start: 'welcome', blocks: [] }, blueprintVersion: 'blueprint-2', idMap: { welcome: 'i-welcome' }, model: 'test-model' }
70
+ };
71
+
72
+ async function newProject(name) {
73
+ const email = 'test-aigen-' + Date.now() + '-' + Math.round(Math.random() * 1e6) + '@email.com';
74
+ const pwd = 'pwd';
75
+ const user = await userService.signup(email, pwd, 'Test', 'User');
76
+ const project = await projectService.create(name || 'test-aigen', user._id);
77
+ return { email: email, pwd: pwd, user: user, project: project, id: String(project._id) };
78
+ }
79
+
80
+ function api(ctx) {
81
+ return {
82
+ post: (path, body) => chai.request(server).post('/' + ctx.id + BASE + path).auth(ctx.email, ctx.pwd).send(body || {}),
83
+ get: (path) => chai.request(server).get('/' + ctx.id + BASE + path).auth(ctx.email, ctx.pwd),
84
+ del: (path) => chai.request(server).delete('/' + ctx.id + BASE + path).auth(ctx.email, ctx.pwd)
85
+ };
86
+ }
87
+
88
+ async function createAgent(ctx, name) {
89
+ const res = await api(ctx).post('/agents', { agent: sampleAgent(name), ai: sampleAi });
90
+ expect(res).to.have.status(201);
91
+ return res.body;
92
+ }
93
+
94
+ describe('aiGenerations module', function () {
95
+ this.timeout(30000);
96
+
97
+ if (!mounted) {
98
+ it('is not mounted without AI_GENERATIONS_ENABLED: routes answer 404', async () => {
99
+ const ctx = await newProject();
100
+ const res = await api(ctx).get('/');
101
+ expect(res).to.have.status(404);
102
+ });
103
+ return;
104
+ }
105
+
106
+ it('reports its status', async () => {
107
+ const ctx = await newProject();
108
+ const res = await api(ctx).get('/');
109
+ expect(res).to.have.status(200);
110
+ expect(res.body.enabled).to.equal(true);
111
+ expect(res.body.transactions).to.be.a('boolean');
112
+ });
113
+
114
+ it('refuses a non-admin', async () => {
115
+ const ctx = await newProject();
116
+ const other = await newProject();
117
+ const res = await chai.request(server).get('/' + ctx.id + BASE + '/').auth(other.email, other.pwd);
118
+ expect(res.status).to.be.oneOf([401, 403]);
119
+ });
120
+
121
+ describe('POST /agents', () => {
122
+
123
+ it('creates the chatbot, its blocks and revision 1 atomically', async () => {
124
+ const ctx = await newProject();
125
+ const body = await createAgent(ctx);
126
+ expect(body.bot_id).to.be.a('string');
127
+ expect(body.revision.seq).to.equal(1);
128
+ expect(body.revision.kind).to.equal('ai_create');
129
+ expect(body.revision.contentHash).to.match(/^sha256:/);
130
+ expect(body.revision.snapshot.intents).to.equal(6);
131
+ expect(body.revision.snapshot.data).to.equal(undefined);
132
+ expect(body.revision.ai.request.finalPrompt).to.equal(undefined);
133
+ expect(body.revision.ai.result.blueprint).to.equal(undefined);
134
+
135
+ const faqs = await Faq.find({ id_faq_kb: body.bot_id }).lean().exec();
136
+ expect(faqs.length).to.equal(6);
137
+ expect(faqs.map(f => f.intent_id).sort()).to.deep.equal(['i-confirm', 'i-default', 'i-fallback', 'i-people', 'i-start', 'i-welcome']);
138
+ expect(faqs.every(f => f.id_project === ctx.id)).to.equal(true);
139
+
140
+ const kb = await Faq_kb.findById(body.bot_id).lean().exec();
141
+ expect(kb.name).to.equal('Prenotazione tavolo');
142
+ expect(kb.attributes.aiGeneration).to.equal(undefined);
143
+
144
+ const stored = await FaqKbRevision.findOne({ revision_id: body.revision.revision_id }).exec();
145
+ expect(stored.ai.request.finalPrompt).to.equal('PROMPT FINALE');
146
+ const snapshot = snapshotUtil.decompress(stored.snapshot);
147
+ expect(snapshot.intents.length).to.equal(6);
148
+ expect(snapshot.name).to.equal('Prenotazione tavolo');
149
+ expect(snapshot.attributes.globals).to.equal(undefined);
150
+ });
151
+
152
+ it('validates the body', async () => {
153
+ const ctx = await newProject();
154
+ let res = await api(ctx).post('/agents', { ai: sampleAi });
155
+ expect(res).to.have.status(400);
156
+ const agent = sampleAgent();
157
+ agent.intents[1].intent_display_name = 'start';
158
+ res = await api(ctx).post('/agents', { agent: agent });
159
+ expect(res).to.have.status(400);
160
+ expect(res.body.details.intent_display_name).to.equal('start');
161
+ });
162
+
163
+ it('leaves nothing behind when a block cannot be saved', async () => {
164
+ const ctx = await newProject();
165
+ const agent = sampleAgent('Agente rifiutato');
166
+ // Un blocco che il model rifiuta: `enabled` non convertibile in booleano.
167
+ agent.intents[2].enabled = 'not-a-boolean';
168
+ const res = await api(ctx).post('/agents', { agent: agent });
169
+ expect(res).to.have.status(422);
170
+ expect(res.body.details.blocks[0].intent_display_name).to.equal('Quante persone');
171
+ // Il progetto crea da solo un bot di default, quindi si conta solo l'agente rifiutato.
172
+ expect(await Faq_kb.countDocuments({ id_project: ctx.id, name: 'Agente rifiutato' }).exec()).to.equal(0);
173
+ expect(await Faq.countDocuments({ id_project: ctx.id, intent_id: 'i-welcome' }).exec()).to.equal(0);
174
+ const revisions = await FaqKbRevision.countDocuments({ id_project: ctx.id }).exec();
175
+ expect(revisions).to.equal(0);
176
+ });
177
+ });
178
+
179
+ describe('hash, checkpoint, list', () => {
180
+
181
+ it('tracks manual changes with the hash', async () => {
182
+ const ctx = await newProject();
183
+ const created = await createAgent(ctx);
184
+ let res = await api(ctx).get('/agents/' + created.bot_id + '/hash');
185
+ expect(res).to.have.status(200);
186
+ expect(res.body.contentHash).to.equal(created.revision.contentHash);
187
+ expect(res.body.modified).to.equal(false);
188
+
189
+ // Spostare un blocco non cambia l'impronta
190
+ await Faq.updateOne({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }, { $set: { 'attributes.position': { x: 999, y: 999 } } }).exec();
191
+ res = await api(ctx).get('/agents/' + created.bot_id + '/hash');
192
+ expect(res.body.modified).to.equal(false);
193
+
194
+ // Cambiare un testo sì
195
+ await Faq.updateOne({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }, { $set: { actions: [{ _tdActionType: 'reply', text: 'Testo cambiato' }] } }).exec();
196
+ res = await api(ctx).get('/agents/' + created.bot_id + '/hash');
197
+ expect(res.body.modified).to.equal(true);
198
+ expect(res.body.contentHash).to.not.equal(created.revision.contentHash);
199
+ });
200
+
201
+ it('saves a checkpoint and lists the history without heavy fields', async () => {
202
+ const ctx = await newProject();
203
+ const created = await createAgent(ctx);
204
+ let res = await api(ctx).post('/agents/' + created.bot_id + '/revisions', { note: 'Prima della demo' });
205
+ expect(res).to.have.status(201);
206
+ expect(res.body.revision.seq).to.equal(2);
207
+ expect(res.body.revision.kind).to.equal('checkpoint');
208
+ expect(res.body.revision.note).to.equal('Prima della demo');
209
+ expect(res.body.revision.parent_id).to.equal(created.revision.revision_id);
210
+
211
+ res = await api(ctx).get('/agents/' + created.bot_id + '/revisions?limit=10');
212
+ expect(res).to.have.status(200);
213
+ expect(res.body.total).to.equal(2);
214
+ expect(res.body.items[0].seq).to.equal(2);
215
+ expect(res.body.items[1].snapshot.data).to.equal(undefined);
216
+ expect(res.body.items[1].ai.request.finalPrompt).to.equal(undefined);
217
+ expect(res.body.items[1].ai.result.blueprint).to.equal(undefined);
218
+ expect(res.body.items[1].ai.result.idMap.welcome).to.equal('i-welcome');
219
+
220
+ res = await api(ctx).get('/revisions/' + created.revision.revision_id + '?snapshot=true');
221
+ expect(res).to.have.status(200);
222
+ expect(res.body.ai.request.finalPrompt).to.equal('PROMPT FINALE');
223
+ expect(res.body.ai.result.blueprint.start).to.equal('welcome');
224
+ expect(res.body.snapshotData.intents.length).to.equal(6);
225
+ expect(res.body.snapshot.data).to.equal(undefined);
226
+ });
227
+
228
+ it('keeps projects apart', async () => {
229
+ const ctx = await newProject();
230
+ const other = await newProject();
231
+ const created = await createAgent(ctx);
232
+ let res = await api(other).get('/agents/' + created.bot_id + '/revisions');
233
+ expect(res).to.have.status(404);
234
+ res = await api(other).get('/revisions/' + created.revision.revision_id);
235
+ expect(res).to.have.status(404);
236
+ res = await api(other).post('/agents/' + created.bot_id + '/restore', { revision_id: created.revision.revision_id });
237
+ expect(res).to.have.status(404);
238
+ });
239
+ });
240
+
241
+ describe('POST /agents/:id/restore', () => {
242
+
243
+ it('restores in place, keeps ids, backs up manual changes first, and is reversible', async () => {
244
+ const ctx = await newProject();
245
+ const created = await createAgent(ctx);
246
+ const r1 = created.revision;
247
+
248
+ // Modifiche a mano: testo cambiato e blocco nuovo
249
+ await Faq.updateOne({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }, { $set: { actions: [{ _tdActionType: 'reply', text: 'Testo delle 11:30' }] } }).exec();
250
+ await Faq.create({ id_faq_kb: created.bot_id, id_project: ctx.id, createdBy: 'test', intent_id: 'i-orari', intent_display_name: 'Orari', actions: [] });
251
+ let hash = await api(ctx).get('/agents/' + created.bot_id + '/hash');
252
+ expect(hash.body.modified).to.equal(true);
253
+
254
+ // Impronta sbagliata: 409, niente cambia
255
+ let res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: r1.revision_id, expectedHash: 'sha256:wrong' });
256
+ expect(res).to.have.status(409);
257
+ expect(await Faq.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(7);
258
+
259
+ // Ripristino della revisione 1
260
+ res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: r1.revision_id, expectedHash: hash.body.contentHash });
261
+ expect(res).to.have.status(200);
262
+ expect(res.body.backup.kind).to.equal('auto');
263
+ expect(res.body.backup.reason).to.equal('before_restore');
264
+ expect(res.body.backup.seq).to.equal(2);
265
+ expect(res.body.backup.snapshot.intents).to.equal(7);
266
+ expect(res.body.revision.kind).to.equal('restore');
267
+ expect(res.body.revision.reason).to.equal('in_place');
268
+ expect(res.body.revision.seq).to.equal(3);
269
+ expect(res.body.revision.parent_id).to.equal(r1.revision_id);
270
+ expect(res.body.revision.contentHash).to.equal(r1.contentHash);
271
+
272
+ const faqs = await Faq.find({ id_faq_kb: created.bot_id }).lean().exec();
273
+ expect(faqs.length).to.equal(6);
274
+ const welcome = faqs.find(f => f.intent_id === 'i-welcome');
275
+ expect(welcome.actions[0].text).to.equal('Ciao! Vuoi prenotare un tavolo?');
276
+ expect(faqs.find(f => f.intent_id === 'i-orari')).to.equal(undefined);
277
+ expect(faqs.every(f => f.id_faq_kb === created.bot_id)).to.equal(true);
278
+ const kb = await Faq_kb.findById(created.bot_id).lean().exec();
279
+ expect(kb.modified).to.equal(true);
280
+
281
+ // Nessun blocco di staging rimasto
282
+ expect(await Faq.countDocuments({ id_faq_kb: new RegExp('^' + created.bot_id + '~') }).exec()).to.equal(0);
283
+
284
+ // Il ripristino è reversibile: si torna alla revisione automatica
285
+ res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: res.body.backup.revision_id });
286
+ expect(res).to.have.status(200);
287
+ expect(res.body.backup).to.equal(null); // lo stato corrente coincideva con la revisione 3
288
+ expect(res.body.revision.seq).to.equal(4);
289
+ const again = await Faq.find({ id_faq_kb: created.bot_id }).lean().exec();
290
+ expect(again.length).to.equal(7);
291
+ expect(again.find(f => f.intent_id === 'i-welcome').actions[0].text).to.equal('Testo delle 11:30');
292
+ });
293
+
294
+ it('keeps the globals of the draft', async () => {
295
+ const ctx = await newProject();
296
+ const created = await createAgent(ctx);
297
+ await Faq_kb.updateOne({ _id: created.bot_id }, { $set: { 'attributes.globals': { API_KEY: 'secret' }, name: 'Rinominato' } }).exec();
298
+ const res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: created.revision.revision_id });
299
+ expect(res).to.have.status(200);
300
+ const kb = await Faq_kb.findById(created.bot_id).lean().exec();
301
+ expect(kb.name).to.equal('Prenotazione tavolo');
302
+ expect(kb.attributes.globals.API_KEY).to.equal('secret');
303
+ expect(kb.attributes.variables.people).to.equal('people');
304
+ const stored = await FaqKbRevision.findOne({ revision_id: res.body.revision.revision_id }).exec();
305
+ expect(snapshotUtil.decompress(stored.snapshot).attributes.globals).to.equal(undefined);
306
+ });
307
+
308
+ it('restores as a copy into a new chatbot', async () => {
309
+ const ctx = await newProject();
310
+ const created = await createAgent(ctx);
311
+ const res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: created.revision.revision_id, as_copy: true });
312
+ expect(res).to.have.status(201);
313
+ expect(res.body.bot_id).to.not.equal(created.bot_id);
314
+ expect(res.body.revision.seq).to.equal(1);
315
+ expect(res.body.revision.kind).to.equal('restore');
316
+ expect(res.body.revision.reason).to.equal('as_copy');
317
+ expect(res.body.revision.parent_id).to.equal(created.revision.revision_id);
318
+ const copyFaqs = await Faq.find({ id_faq_kb: res.body.bot_id }).lean().exec();
319
+ expect(copyFaqs.length).to.equal(6);
320
+ expect(await Faq.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(6);
321
+ const copy = await Faq_kb.findById(res.body.bot_id).lean().exec();
322
+ expect(copy.name).to.equal('Prenotazione tavolo (copia)');
323
+ });
324
+
325
+ it('links a release and restores from it', async () => {
326
+ const ctx = await newProject();
327
+ const created = await createAgent(ctx);
328
+ // Una release come la crea la pubblicazione: copia con root_id
329
+ const release = await Faq_kb.create({ name: 'Prenotazione tavolo', id_project: ctx.id, createdBy: 'test', type: 'tilebot', trashed: true, root_id: created.bot_id, publishedAt: new Date(), release_note: 'v1' });
330
+ const faqs = await Faq.find({ id_faq_kb: created.bot_id }).lean().exec();
331
+ await Faq.insertMany(faqs.map(f => Object.assign({}, f, { _id: undefined, id_faq_kb: String(release._id) })));
332
+
333
+ let res = await api(ctx).post('/agents/' + created.bot_id + '/revisions/release', { release_id: String(release._id) });
334
+ expect(res).to.have.status(201);
335
+ expect(res.body.revision.kind).to.equal('release');
336
+ expect(res.body.revision.release_id).to.equal(String(release._id));
337
+ expect(res.body.revision.note).to.equal('v1');
338
+ res = await api(ctx).post('/agents/' + created.bot_id + '/revisions/release', { release_id: String(release._id) });
339
+ expect(res).to.have.status(200);
340
+ expect(res.body.already_linked).to.equal(true);
341
+
342
+ const stranger = await Faq_kb.create({ name: 'Altro', id_project: ctx.id, createdBy: 'test', type: 'tilebot' });
343
+ res = await api(ctx).post('/agents/' + created.bot_id + '/revisions/release', { release_id: String(stranger._id) });
344
+ expect(res).to.have.status(404);
345
+
346
+ // La bozza cambia, poi si ripristina dalla release
347
+ await Faq.deleteOne({ id_faq_kb: created.bot_id, intent_id: 'i-fallback' }).exec();
348
+ res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { release_id: String(release._id) });
349
+ expect(res).to.have.status(200);
350
+ expect(res.body.backup.kind).to.equal('auto');
351
+ expect(await Faq.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(6);
352
+ });
353
+
354
+ it('refuses a trashed chatbot and a revision without snapshot', async () => {
355
+ const ctx = await newProject();
356
+ const created = await createAgent(ctx);
357
+ const release = await Faq_kb.create({ name: 'R', id_project: ctx.id, createdBy: 'test', type: 'tilebot', trashed: true, root_id: created.bot_id, publishedAt: new Date() });
358
+ let res = await api(ctx).post('/agents/' + created.bot_id + '/revisions/release', { release_id: String(release._id) });
359
+ expect(res).to.have.status(201);
360
+ res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: res.body.revision.revision_id });
361
+ expect(res).to.have.status(409);
362
+
363
+ await Faq_kb.updateOne({ _id: created.bot_id }, { $set: { trashed: true } }).exec();
364
+ res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: created.revision.revision_id });
365
+ expect(res).to.have.status(409);
366
+ });
367
+ });
368
+
369
+ describe('POST /agents/:id/restore dry_run', () => {
370
+ it('lists the external references that no longer exist, without writing', async () => {
371
+ const ctx = await newProject();
372
+ const agent = sampleAgent();
373
+ agent.intents[3].actions = [{ _tdActionType: 'department', depName: 'Ghost department', triggerBot: false }];
374
+ agent.intents[4].actions = [{ _tdActionType: 'replacebotv3', botId: '5f0000000000000000000000', useSlug: false }];
375
+ const res0 = await api(ctx).post('/agents', { agent: agent, ai: sampleAi });
376
+ expect(res0).to.have.status(201);
377
+ const created = res0.body;
378
+ await Faq.updateOne({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }, { $set: { actions: [{ _tdActionType: 'reply', text: 'x' }] } }).exec();
379
+ const res = await api(ctx).post('/agents/' + created.bot_id + '/restore', { revision_id: created.revision.revision_id, dry_run: true, expectedHash: created.revision.contentHash });
380
+ expect(res).to.have.status(200);
381
+ expect(res.body.dry_run).to.equal(true);
382
+ expect(res.body.changed).to.equal(true);
383
+ expect(res.body.intents).to.equal(6);
384
+ const kinds = res.body.missing.map(m => m.kind + ':' + m.value).sort();
385
+ expect(kinds).to.deep.equal(['chatbot:5f0000000000000000000000', 'department:Ghost department']);
386
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(1);
387
+ const faqs = await Faq.find({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }).lean().exec();
388
+ expect(faqs[0].actions[0].text).to.equal('x');
389
+ });
390
+ });
391
+
392
+ describe('listeners (fase F)', () => {
393
+ it('cleanup: deleting the chatbot deletes its history', async () => {
394
+ const ctx = await newProject();
395
+ const created = await createAgent(ctx);
396
+ const kb = await Faq_kb.findById(created.bot_id).exec();
397
+ botEvent.emit('faqbot.delete', kb);
398
+ await sleep(300);
399
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(0);
400
+ });
401
+
402
+ it('autosnapshot: manual changes produce one auto revision, only if something changed and not right after a module revision', async () => {
403
+ const ctx = await newProject();
404
+ const created = await createAgent(ctx);
405
+ const faq = await Faq.findOne({ id_faq_kb: created.bot_id, intent_id: 'i-welcome' }).exec();
406
+ // Gli eventi (anche quelli della creazione) lasciano un solo timer in attesa per agente (debounce)
407
+ faqBotEvent.emit('faq.update', faq);
408
+ const count = listeners.pendingCount();
409
+ faqBotEvent.emit('faq.update', faq);
410
+ expect(listeners.isPending(created.bot_id)).to.equal(true);
411
+ expect(listeners.pendingCount()).to.equal(count);
412
+ // Nessun cambiamento reale: nessuna revisione
413
+ await sleep(250);
414
+ await listeners.snapshot(created.bot_id);
415
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(1);
416
+ // Un cambiamento vero: una revisione automatica
417
+ await Faq.updateOne({ _id: faq._id }, { $set: { actions: [{ _tdActionType: 'reply', text: 'cambiato a mano' }] } }).exec();
418
+ await listeners.snapshot(created.bot_id);
419
+ const revisions = await FaqKbRevision.find({ id_faq_kb: created.bot_id }).sort({ seq: 1 }).lean().exec();
420
+ expect(revisions.length).to.equal(2);
421
+ expect(revisions[1].kind).to.equal('auto');
422
+ expect(revisions[1].reason).to.equal('manual_changes');
423
+ expect(revisions[1].createdBy).to.equal('system');
424
+ expect(revisions[1].snapshot.intents).to.equal(6);
425
+ // Subito dopo una revisione del modulo (qui: quella appena scritta) un altro cambiamento non produce snapshot
426
+ await Faq.updateOne({ _id: faq._id }, { $set: { actions: [{ _tdActionType: 'reply', text: 'cambiato di nuovo' }] } }).exec();
427
+ await listeners.snapshot(created.bot_id);
428
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(2);
429
+ });
430
+ });
431
+
432
+ describe('POST /agents/:id/edit', () => {
433
+
434
+ it('applies operations and records an ai_edit revision with changes', async () => {
435
+ const ctx = await newProject();
436
+ const created = await createAgent(ctx);
437
+ const operations = [
438
+ { type: 'post', intent: { intent_id: 'i-orari', intent_display_name: 'Orari', actions: [{ _tdActionType: 'reply', text: '12-15, 19-23' }], attributes: {} } },
439
+ { type: 'put', intent: { intent_id: 'i-confirm', intent_display_name: 'Conferma', actions: [{ _tdActionType: 'reply', text: 'Prenotato!' }] } },
440
+ { type: 'delete', intent: { intent_id: 'i-fallback' } }
441
+ ];
442
+ const res = await api(ctx).post('/agents/' + created.bot_id + '/edit', {
443
+ operations: operations,
444
+ ai: { request: { finalPrompt: 'aggiungi gli orari' }, result: { model: 'test-model' } },
445
+ expectedHash: created.revision.contentHash
446
+ });
447
+ expect(res).to.have.status(200);
448
+ expect(res.body.backup).to.equal(null);
449
+ expect(res.body.revision.kind).to.equal('ai_edit');
450
+ expect(res.body.revision.seq).to.equal(2);
451
+ expect(res.body.changes).to.deep.equal({ added: ['i-orari'], updated: ['i-confirm'], removed: ['i-fallback'] });
452
+ expect(res.body.revision.ai.result.changes.added).to.deep.equal(['i-orari']);
453
+
454
+ const faqs = await Faq.find({ id_faq_kb: created.bot_id }).lean().exec();
455
+ expect(faqs.length).to.equal(6);
456
+ expect(faqs.find(f => f.intent_id === 'i-confirm').actions[0].text).to.equal('Prenotato!');
457
+ expect(faqs.find(f => f.intent_id === 'i-fallback')).to.equal(undefined);
458
+ expect(faqs.find(f => f.intent_id === 'i-orari').createdBy).to.equal(String(ctx.user._id));
459
+ });
460
+
461
+ it('rejects a stale expectedHash and an unknown block', async () => {
462
+ const ctx = await newProject();
463
+ const created = await createAgent(ctx);
464
+ let res = await api(ctx).post('/agents/' + created.bot_id + '/edit', { operations: [{ type: 'delete', intent: { intent_id: 'i-fallback' } }], expectedHash: 'sha256:stale' });
465
+ expect(res).to.have.status(409);
466
+ res = await api(ctx).post('/agents/' + created.bot_id + '/edit', { operations: [{ type: 'delete', intent: { intent_id: 'nope' } }] });
467
+ expect(res).to.have.status(404);
468
+ expect(await Faq.countDocuments({ id_faq_kb: created.bot_id }).exec()).to.equal(6);
469
+ });
470
+ });
471
+
472
+ describe('DELETE /agents/:id/revisions', () => {
473
+ it('deletes the history of the chatbot only', async () => {
474
+ const ctx = await newProject();
475
+ const a = await createAgent(ctx, 'A');
476
+ const b = await createAgent(ctx, 'B');
477
+ const res = await api(ctx).del('/agents/' + a.bot_id + '/revisions');
478
+ expect(res).to.have.status(200);
479
+ expect(res.body.deleted).to.equal(1);
480
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: a.bot_id }).exec()).to.equal(0);
481
+ expect(await FaqKbRevision.countDocuments({ id_faq_kb: b.bot_id }).exec()).to.equal(1);
482
+ expect(await Faq.countDocuments({ id_faq_kb: a.bot_id }).exec()).to.equal(6);
483
+ });
484
+ });
485
+
486
+ describe('snapshot helpers', () => {
487
+ it('hash ignores position and color and key order', () => {
488
+ const a = [{ intent_id: '1', intent_display_name: 'x', actions: [{ b: 1, a: 2 }], attributes: { position: { x: 1 }, color: 'red', other: 1 } }];
489
+ const b = [{ attributes: { other: 1, color: 'blue', position: { x: 9 } }, actions: [{ a: 2, b: 1 }], intent_display_name: 'x', intent_id: '1' }];
490
+ expect(snapshotUtil.hashIntents(a)).to.equal(snapshotUtil.hashIntents(b));
491
+ const c = [{ intent_id: '1', intent_display_name: 'x', actions: [{ b: 1, a: 3 }], attributes: { other: 1 } }];
492
+ expect(snapshotUtil.hashIntents(a)).to.not.equal(snapshotUtil.hashIntents(c));
493
+ });
494
+
495
+ it('compress and decompress round-trip', () => {
496
+ const snapshot = { name: 'n', attributes: {}, intents: [{ intent_id: '1', intent_display_name: 'x' }] };
497
+ const stored = snapshotUtil.compress(snapshot);
498
+ expect(stored.encoding).to.equal('gzip+json');
499
+ expect(stored.intents).to.equal(1);
500
+ expect(snapshotUtil.decompress(stored)).to.deep.equal(snapshot);
501
+ });
502
+ });
503
+ });
@@ -58,6 +58,8 @@ class PubModulesManager {
58
58
 
59
59
  this.cannedResponseRoute = undefined;
60
60
 
61
+ this.aiGenerationsRoute = undefined;
62
+
61
63
  this.trigger = undefined;
62
64
  this.triggerRoute = undefined;
63
65
 
@@ -168,6 +170,11 @@ class PubModulesManager {
168
170
  winston.info("ModulesManager canned controller loaded");
169
171
  }
170
172
 
173
+ if (this.aiGenerationsRoute) {
174
+ app.use('/:projectid/modules/ai-generations', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRole('admin')], this.aiGenerationsRoute);
175
+ winston.info("ModulesManager aiGenerations controller loaded");
176
+ }
177
+
171
178
  if (this.triggerRoute) {
172
179
  app.use('/:projectid/modules/triggers', [passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRole('admin')], this.triggerRoute);
173
180
  winston.info("ModulesManager trigger controller loaded");
@@ -498,6 +505,16 @@ class PubModulesManager {
498
505
 
499
506
 
500
507
 
508
+ // Storia e ripristino degli agenti: attivo SOLO con AI_GENERATIONS_ENABLED=true
509
+ if (process.env.AI_GENERATIONS_ENABLED === 'true') {
510
+ try {
511
+ this.aiGenerationsRoute = require('./aiGenerations').aiGenerationsRoute;
512
+ winston.info("ModulesManager aiGenerations initialized");
513
+ } catch(err) {
514
+ winston.error("ModulesManager error initializing aiGenerations module", err);
515
+ }
516
+ }
517
+
501
518
  try {
502
519
  this.cannedResponseRoute = require('./canned').cannedResponseRoute;
503
520
  winston.debug("this.cannedResponseRoute:"+ this.cannedResponseRoute);