@tiledesk/tiledesk-server 2.28.2 → 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.
- package/CHANGELOG.md +20 -0
- package/app.js +6 -35
- package/config/native_mcp/native_mcp_servers.json +7 -0
- package/controllers/chatbot.controller.js +659 -0
- package/controllers/intents.controller.js +139 -0
- package/errorCodes.js +2 -1
- package/event/botEvent.js +43 -3
- package/middleware/errorHandler.js +41 -0
- package/models/chatbotTemplates.js +4 -0
- package/models/faq.js +10 -0
- package/models/faq_kb.js +49 -0
- package/package.json +2 -2
- package/pubmodules/aiGenerations/README.md +154 -0
- package/pubmodules/aiGenerations/index.js +7 -0
- package/pubmodules/aiGenerations/listeners.js +96 -0
- package/pubmodules/aiGenerations/models/faqKbRevision.js +51 -0
- package/pubmodules/aiGenerations/routes/aiGenerations.js +426 -0
- package/pubmodules/aiGenerations/services/agentWriter.js +286 -0
- package/pubmodules/aiGenerations/services/atomic.js +70 -0
- package/pubmodules/aiGenerations/services/references.js +78 -0
- package/pubmodules/aiGenerations/services/revisionService.js +169 -0
- package/pubmodules/aiGenerations/services/snapshot.js +109 -0
- package/pubmodules/aiGenerations/test/aiGenerationsRoute.js +503 -0
- package/pubmodules/pubModulesManager.js +17 -0
- package/routes/faq.js +10 -3
- package/routes/faq_kb.js +716 -411
- package/services/chatbotService.js +84 -5
- package/services/faqService.js +159 -45
- package/services/v3FlowService.js +416 -0
- package/template/chatbot/blank.js +21 -3
- package/template/chatbot/blank_subagent.js +139 -0
- package/template/chatbot/blank_voice.js +21 -4
- package/template/chatbot/blank_voice_twilio.js +21 -5
- package/template/chatbot/index.js +1 -0
- package/template/chatbot/subagent_notes.js +30 -0
- package/test/faqRoute.js +83 -0
- package/test/faqkbRoute.js +982 -523
- package/test/faqkbV3Route.js +436 -0
- package/test/fixtures/example-json-with-subagents.txt +1 -0
- package/test/nativemcp.test.js +6 -1
- package/test/v3FlowService.js +187 -0
- package/test/webhookRoute.js +2 -1
- package/utils/AppError.js +9 -0
- package/utils/asyncHandler.js +10 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const Intent = require("../models/faq");
|
|
2
|
+
const AppError = require("../utils/AppError");
|
|
3
|
+
const faqBotEvent = require("../event/faqBotEvent");
|
|
4
|
+
const winston = require("../config/winston");
|
|
5
|
+
|
|
6
|
+
const INTENT_FIELDS = [
|
|
7
|
+
"intent_display_name",
|
|
8
|
+
"intent_id",
|
|
9
|
+
"question",
|
|
10
|
+
"answer",
|
|
11
|
+
"reply",
|
|
12
|
+
"form",
|
|
13
|
+
"enabled",
|
|
14
|
+
"webhook_enabled",
|
|
15
|
+
"language",
|
|
16
|
+
"actions",
|
|
17
|
+
"attributes"
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
class IntentsController {
|
|
21
|
+
|
|
22
|
+
constructor() { }
|
|
23
|
+
|
|
24
|
+
_mapIntent(chatbot_id, id_project, user_id, intent) {
|
|
25
|
+
const mapped = {
|
|
26
|
+
id_faq_kb: chatbot_id,
|
|
27
|
+
id_project,
|
|
28
|
+
createdBy: user_id
|
|
29
|
+
};
|
|
30
|
+
for (const field of INTENT_FIELDS) {
|
|
31
|
+
if (intent[field] !== undefined) {
|
|
32
|
+
mapped[field] = intent[field];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return mapped;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async create(chatbot_id, id_project, user_id, intent, { skipDuplicates = false } = {}) {
|
|
39
|
+
try {
|
|
40
|
+
const faq = await Intent.create(
|
|
41
|
+
this._mapIntent(chatbot_id, id_project, user_id, intent)
|
|
42
|
+
);
|
|
43
|
+
faqBotEvent.emit("faq.create", faq);
|
|
44
|
+
return faq;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 11000) {
|
|
47
|
+
if (skipDuplicates) {
|
|
48
|
+
winston.debug("Skip duplicated intent_display_name");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
throw new AppError(409, "Duplicate intent_display_name");
|
|
52
|
+
}
|
|
53
|
+
winston.error("(IntentsController) create error: ", err);
|
|
54
|
+
throw new AppError(500, "Error creating intent");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async createMany(chatbot_id, id_project, user_id, intents, options) {
|
|
59
|
+
if (!intents?.length) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const results = await Promise.allSettled(
|
|
63
|
+
intents.map((intent) =>
|
|
64
|
+
this.create(chatbot_id, id_project, user_id, intent, options)
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
return results
|
|
68
|
+
.filter((r) => r.status === "fulfilled" && r.value)
|
|
69
|
+
.map((r) => r.value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async upsert(chatbot_id, id_project, user_id, intent) {
|
|
73
|
+
try {
|
|
74
|
+
const savingResult = await Intent.findOneAndUpdate(
|
|
75
|
+
{ id_faq_kb: chatbot_id, intent_display_name: intent.intent_display_name },
|
|
76
|
+
this._mapIntent(chatbot_id, id_project, user_id, intent),
|
|
77
|
+
{ new: true, upsert: true, rawResult: true }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (!savingResult?.value) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (savingResult.lastErrorObject?.updatedExisting === true) {
|
|
85
|
+
winston.verbose("updated existing intent");
|
|
86
|
+
faqBotEvent.emit("faq.update", savingResult.value);
|
|
87
|
+
} else {
|
|
88
|
+
winston.verbose("new intent created");
|
|
89
|
+
faqBotEvent.emit("faq.create", savingResult.value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return savingResult.value;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
winston.error("(IntentsController) upsert error: ", err);
|
|
95
|
+
throw new AppError(500, "Error upserting intent");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async upsertMany(chatbot_id, id_project, user_id, intents) {
|
|
100
|
+
if (!intents?.length) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const results = await Promise.allSettled(
|
|
104
|
+
intents.map((intent) =>
|
|
105
|
+
this.upsert(chatbot_id, id_project, user_id, intent)
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
return results
|
|
109
|
+
.filter((r) => r.status === "fulfilled" && r.value)
|
|
110
|
+
.map((r) => r.value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async deleteByChatbotId(chatbot_id) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await Intent.deleteMany({ id_faq_kb: chatbot_id });
|
|
116
|
+
winston.verbose("All faq for chatbot " + chatbot_id + " deleted successfully");
|
|
117
|
+
return result;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
winston.error("(IntentsController) deleteByChatbotId error: ", err);
|
|
120
|
+
throw new AppError(500, `Error deleting intents for chatbot ${chatbot_id}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getIntents(chatbot_id) {
|
|
125
|
+
try {
|
|
126
|
+
const intents = await Intent
|
|
127
|
+
.find({ id_faq_kb: chatbot_id })
|
|
128
|
+
.lean()
|
|
129
|
+
.exec();
|
|
130
|
+
return intents;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
winston.error("(IntentsController) getIntents error: ", err);
|
|
133
|
+
throw new AppError(500, `Error finding intents with chatbot id ${chatbot_id}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const intentsController = new IntentsController();
|
|
139
|
+
module.exports = intentsController;
|
package/errorCodes.js
CHANGED
package/event/botEvent.js
CHANGED
|
@@ -135,7 +135,9 @@ class BotEvent extends EventEmitter {
|
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
const trashedAt = chatbot.trashedAt || new Date();
|
|
139
|
+
|
|
140
|
+
await Faq.updateMany({ id_faq_kb: chatbot._id }, { trashed: true, trashedAt: trashedAt }).catch((err) => {
|
|
139
141
|
winston.error("Event faqbot.update.virtual.delete error updating faqs ", err);
|
|
140
142
|
})
|
|
141
143
|
|
|
@@ -143,6 +145,9 @@ class BotEvent extends EventEmitter {
|
|
|
143
145
|
winston.error("Error deleting webhook on chatbot deleting: ", err);
|
|
144
146
|
})
|
|
145
147
|
|
|
148
|
+
// Soft-delete subagents of the draft chatbot (+ their intents) so TTL applies
|
|
149
|
+
await trashSubagentsByParentIds([chatbot._id], trashedAt);
|
|
150
|
+
|
|
146
151
|
let publishedChatbots = await Faq_kb.find({ root_id: chatbot._id }, { _id: 1 }).catch((err) => {
|
|
147
152
|
winston.error("Event faqbot.update.virtual.delete error getting all published chatbots ", err);
|
|
148
153
|
})
|
|
@@ -161,13 +166,16 @@ class BotEvent extends EventEmitter {
|
|
|
161
166
|
for (const batch of batches) {
|
|
162
167
|
await Faq_kb.updateMany(
|
|
163
168
|
{ _id: { $in: batch } },
|
|
164
|
-
{ $set: { trashed: true, trashedAt:
|
|
169
|
+
{ $set: { trashed: true, trashedAt: trashedAt } }
|
|
165
170
|
);
|
|
166
171
|
|
|
167
172
|
await Faq.updateMany(
|
|
168
173
|
{ id_faq_kb: { $in: batch } },
|
|
169
|
-
{ $set: { trashed: true, trashedAt:
|
|
174
|
+
{ $set: { trashed: true, trashedAt: trashedAt } }
|
|
170
175
|
);
|
|
176
|
+
|
|
177
|
+
// Soft-delete subagents linked to published copies (+ their intents)
|
|
178
|
+
await trashSubagentsByParentIds(batch, trashedAt);
|
|
171
179
|
|
|
172
180
|
await sleep(sleep_ms);
|
|
173
181
|
}
|
|
@@ -175,6 +183,38 @@ class BotEvent extends EventEmitter {
|
|
|
175
183
|
}
|
|
176
184
|
})
|
|
177
185
|
|
|
186
|
+
async function trashSubagentsByParentIds(parentIds, trashedAt) {
|
|
187
|
+
if (!parentIds || parentIds.length === 0) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const parentIdStrings = parentIds.map(id => id.toString());
|
|
192
|
+
let subagents = await Faq_kb.find({ parent_id: { $in: parentIdStrings } }, { _id: 1 }).catch((err) => {
|
|
193
|
+
winston.error("Event faqbot.update.virtual.delete error getting subagents ", err);
|
|
194
|
+
return [];
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!subagents || subagents.length === 0) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const subagentIds = subagents.map(s => s._id);
|
|
202
|
+
|
|
203
|
+
await Faq_kb.updateMany(
|
|
204
|
+
{ _id: { $in: subagentIds } },
|
|
205
|
+
{ $set: { trashed: true, trashedAt: trashedAt } }
|
|
206
|
+
).catch((err) => {
|
|
207
|
+
winston.error("Event faqbot.update.virtual.delete error updating subagents ", err);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await Faq.updateMany(
|
|
211
|
+
{ id_faq_kb: { $in: subagentIds } },
|
|
212
|
+
{ $set: { trashed: true, trashedAt: trashedAt } }
|
|
213
|
+
).catch((err) => {
|
|
214
|
+
winston.error("Event faqbot.update.virtual.delete error updating subagent faqs ", err);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
function sleep(ms) {
|
|
179
219
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
180
220
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const AppError = require('../utils/AppError');
|
|
2
|
+
const winston = require('../config/winston');
|
|
3
|
+
|
|
4
|
+
function errorHandler(err, req, res, next) {
|
|
5
|
+
if (err instanceof AppError) {
|
|
6
|
+
winston.error(err.message, err);
|
|
7
|
+
return res.status(err.status).json({
|
|
8
|
+
success: false,
|
|
9
|
+
message: err.message
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
winston.debug("err.name", err.name);
|
|
14
|
+
if (err.name === "IpDeniedError") {
|
|
15
|
+
winston.debug("IpDeniedError");
|
|
16
|
+
return res.status(401).json({ err: "error ip filter" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const realIp = req.headers['x-forwarded-for']?.split(',')[0] || req.headers['x-real-ip'] || req.ip;
|
|
20
|
+
|
|
21
|
+
// emitted by multer when the file is too big
|
|
22
|
+
if (err.code === "LIMIT_FILE_SIZE") {
|
|
23
|
+
winston.debug("LIMIT_FILE_SIZE");
|
|
24
|
+
winston.warn(`LIMIT_FILE_SIZE on ${req.originalUrl}`, {
|
|
25
|
+
limit: process.env.MAX_UPLOAD_FILE_SIZE,
|
|
26
|
+
ip: req.ip,
|
|
27
|
+
realIp: realIp
|
|
28
|
+
});
|
|
29
|
+
return res.status(413).json({ err: "Content Too Large", limit_file_size: process.env.MAX_UPLOAD_FILE_SIZE });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (err.type === "entity.too.large" || err.name === "PayloadTooLargeError") {
|
|
33
|
+
winston.warn("Payload too large", { expected: err.expected, limit: err.limit, length: err.length });
|
|
34
|
+
return res.status(413).json({ err: "Request entity too large", limit: err.limit });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
winston.error("General error: ", err);
|
|
38
|
+
return res.status(500).json({ err: "error" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = errorHandler;
|
package/models/faq.js
CHANGED
|
@@ -192,6 +192,16 @@ FaqSchema.index(
|
|
|
192
192
|
{ partialFilterExpression: { "actions.namespace": { $exists: true } } }
|
|
193
193
|
);
|
|
194
194
|
|
|
195
|
+
FaqSchema.index(
|
|
196
|
+
{ "actions.botId": 1 },
|
|
197
|
+
{ partialFilterExpression: { "actions.botId": { $exists: true } } }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
FaqSchema.index(
|
|
201
|
+
{ "actions.botSlug": 1 },
|
|
202
|
+
{ partialFilterExpression: { "actions.botSlug": { $exists: true } } }
|
|
203
|
+
);
|
|
204
|
+
|
|
195
205
|
FaqSchema.index(
|
|
196
206
|
{ trashedAt: 1 },
|
|
197
207
|
{ expireAfterSeconds: trashExpirationTime }
|
package/models/faq_kb.js
CHANGED
|
@@ -7,6 +7,26 @@ const { stringify } = require('uuid');
|
|
|
7
7
|
var defaultFullTextLanguage = process.env.DEFAULT_FULLTEXT_INDEX_LANGUAGE || "none";
|
|
8
8
|
let trashExpirationTime = Number(process.env.CHATBOT_TRASH_TTL_SECONDS) || 60 * 60 * 24 * 30; // 30 days
|
|
9
9
|
|
|
10
|
+
const ConnectorRecordSchema = new Schema({
|
|
11
|
+
id: { type: String, required: true },
|
|
12
|
+
from: {
|
|
13
|
+
intentId: { type: String, required: true },
|
|
14
|
+
actionId: { type: String, required: true },
|
|
15
|
+
slot: { type: String, required: true }
|
|
16
|
+
},
|
|
17
|
+
to: {
|
|
18
|
+
intentId: { type: String, required: true }
|
|
19
|
+
},
|
|
20
|
+
kind: { type: String, enum: ['block', 'action', 'button', 'case', 'branch'], required: true },
|
|
21
|
+
props: { type: Object, required: false }
|
|
22
|
+
}, { _id: false });
|
|
23
|
+
|
|
24
|
+
const ConnectorsSchema = new Schema({
|
|
25
|
+
version: { type: Number, required: true },
|
|
26
|
+
updatedAt: { type: Date, required: false },
|
|
27
|
+
items: { type: [ConnectorRecordSchema], default: undefined }
|
|
28
|
+
}, { _id: false });
|
|
29
|
+
|
|
10
30
|
var Faq_kbSchema = new Schema({
|
|
11
31
|
name: {
|
|
12
32
|
type: String,
|
|
@@ -77,6 +97,21 @@ var Faq_kbSchema = new Schema({
|
|
|
77
97
|
attributes: {
|
|
78
98
|
type: Object,
|
|
79
99
|
},
|
|
100
|
+
/** The Design Studio version the agent was created for: 'v3' for an agent of the Design Studio V3,
|
|
101
|
+
* absent for every other agent. Written by whoever creates the agent, never changed afterwards. */
|
|
102
|
+
ds_version: {
|
|
103
|
+
type: String,
|
|
104
|
+
enum: ['v3'],
|
|
105
|
+
required: false
|
|
106
|
+
},
|
|
107
|
+
/** V3 agents only: the connectors list, the only truth about the links between blocks
|
|
108
|
+
* (see services/v3FlowService.js). Not selected by default: the lists of bots, the widget and
|
|
109
|
+
* the JWT never carry it; the single GET, the export and the engine ask for it explicitly. */
|
|
110
|
+
connectors: {
|
|
111
|
+
type: ConnectorsSchema,
|
|
112
|
+
required: false,
|
|
113
|
+
select: false
|
|
114
|
+
},
|
|
80
115
|
createdBy: {
|
|
81
116
|
type: String,
|
|
82
117
|
required: true
|
|
@@ -161,6 +196,10 @@ var Faq_kbSchema = new Schema({
|
|
|
161
196
|
release_note: {
|
|
162
197
|
type: String,
|
|
163
198
|
required: false
|
|
199
|
+
},
|
|
200
|
+
parent_id: {
|
|
201
|
+
type: String,
|
|
202
|
+
required: false
|
|
164
203
|
}
|
|
165
204
|
},{
|
|
166
205
|
timestamps: true
|
|
@@ -248,6 +287,16 @@ Faq_kbSchema.index(
|
|
|
248
287
|
{ unique: true, partialFilterExpression: { slug: { $exists: true } } }
|
|
249
288
|
);
|
|
250
289
|
|
|
290
|
+
Faq_kbSchema.index(
|
|
291
|
+
{ id_project: 1, subtype: 1 },
|
|
292
|
+
{ partialFilterExpression: { subtype: { $ne: "subagent" } } }
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
Faq_kbSchema.index(
|
|
296
|
+
{ id_project: 1, parent_id: 1 },
|
|
297
|
+
{ partialFilterExpression: { parent_id: { $exists: true } } }
|
|
298
|
+
);
|
|
299
|
+
|
|
251
300
|
Faq_kbSchema.index(
|
|
252
301
|
{ trashedAt: 1 },
|
|
253
302
|
{ expireAfterSeconds: trashExpirationTime }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiledesk/tiledesk-server",
|
|
3
3
|
"description": "The Tiledesk server module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "3.0.0",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"start": "node ./bin/www",
|
|
7
7
|
"pretest": "mongodb-runner start",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@tiledesk/tiledesk-rasa-connector": "^1.0.10",
|
|
50
50
|
"@tiledesk/tiledesk-sms-connector": "^0.1.13",
|
|
51
51
|
"@tiledesk/tiledesk-telegram-connector": "^0.1.14",
|
|
52
|
-
"@tiledesk/tiledesk-tybot-connector": "^2.
|
|
52
|
+
"@tiledesk/tiledesk-tybot-connector": "^2.3.0",
|
|
53
53
|
"@tiledesk/tiledesk-voice-twilio-connector": "^0.3.2",
|
|
54
54
|
"@tiledesk/tiledesk-vxml-connector": "^0.1.91",
|
|
55
55
|
"@tiledesk/tiledesk-whatsapp-connector": "1.0.31",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Modulo `aiGenerations` — storia e ripristino degli agenti
|
|
2
|
+
|
|
3
|
+
Ogni agente creato o modificato con l'AI conserva la sua storia in una collection a parte, `faq_kb_revisions`, e ogni versione si può ripristinare. Il Design Studio manda al server, in una sola richiesta, l'agente compilato e i dati della generazione (prompt, intervista, Blueprint): il server crea l'agente e i suoi blocchi, salva uno snapshot compresso dell'agente con la sua impronta, e risponde con l'id dell'agente e la revisione 1. Da lì in poi ogni pubblicazione viene collegata alla storia, l'utente può salvare una versione quando vuole, e può ripristinare qualunque revisione o release nella bozza (con gli stessi id dei blocchi) o come agente nuovo. Prima di ogni ripristino o modifica via prompt il server salva da solo la versione corrente, quindi nessuna operazione perde lavoro e ogni ripristino è a sua volta reversibile. Nell'agente non resta nulla: il percorso dei messaggi non è toccato. Il modulo è isolato, si accende con `AI_GENERATIONS_ENABLED=true`, e con la variabile spenta il server è identico a prima.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Attivazione e indipendenza
|
|
8
|
+
|
|
9
|
+
| Variabile | Default | Effetto |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `AI_GENERATIONS_ENABLED` | assente | Con `true` il modulo viene caricato e montato su `/:projectid/modules/ai-generations` (ruolo `admin`). Altrimenti nessuna rotta, nessun model, nessuna collection |
|
|
12
|
+
| `AI_GENERATIONS_TRANSACTIONS` | rilevamento automatico | `true`/`false` forzano l'uso delle transazioni MongoDB. Di default si usano se il database è un replica set |
|
|
13
|
+
| `AI_GENERATIONS_MAX_INTENTS` | 1000 | Blocchi massimi per agente e operazioni massime per modifica |
|
|
14
|
+
| `AI_GENERATIONS_MAX_PER_AGENT` | 50 | Revisioni massime per agente |
|
|
15
|
+
| `AI_GENERATIONS_MAX_CHECKPOINTS` | 20 | Checkpoint manuali conservati per agente |
|
|
16
|
+
| `AI_GENERATIONS_AUTO_TTL_DAYS` | 30 | Giorni di conservazione delle revisioni automatiche |
|
|
17
|
+
| `AI_GENERATIONS_CLEANUP_ENABLED` | assente | Con `true` registra un listener su `faqbot.delete` e `faqbot.deleted`: alla cancellazione di un agente si cancella la sua storia, anche fuori dal DS |
|
|
18
|
+
| `AI_GENERATIONS_AUTOSNAPSHOT_ENABLED` | assente | Con `true` registra un listener su `faq.create`, `faq.update` e `faq.delete`: dopo modifiche a mano ai blocchi si salva una revisione `auto` / `manual_changes`, solo se i blocchi sono cambiati rispetto all'ultima revisione |
|
|
19
|
+
| `AI_GENERATIONS_AUTOSNAPSHOT_DEBOUNCE_MS` | 300000 | Attesa dopo l'ultimo evento prima dello snapshot automatico (un timer per agente) |
|
|
20
|
+
| `AI_GENERATIONS_AUTOSNAPSHOT_RECENT_MS` | = debounce | Una revisione più recente di così è opera del modulo (ripristino, modifica): lo snapshot automatico non scatta |
|
|
21
|
+
|
|
22
|
+
Il modulo vive tutto in `pubmodules/aiGenerations/`. L'unico aggancio sono tre blocchi in `pubmodules/pubModulesManager.js`, sullo schema del modulo `canned`: il campo nel costruttore, il `require` condizionato in `init()`, il `app.use` in `useUnderProjects()`. Del codice esistente il modulo **usa** i model `faq_kb`, `faq`, `department`, `Namespace` e `Table` (in lettura), il servizio `faqService.create`, e gli emettitori `botEvent` e `faqBotEvent`; non modifica nessuna rotta, nessun model, nessun servizio. Registra listener solo con le due variabili di pulizia e snapshot automatico. Il limite del body è quello globale (`JSON_BODY_LIMIT`, 500 KB di default).
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pubmodules/aiGenerations/
|
|
26
|
+
index.js esporta aiGenerationsRoute
|
|
27
|
+
models/faqKbRevision.js model 'faq_kb_revision' → collection 'faq_kb_revisions'
|
|
28
|
+
routes/aiGenerations.js le rotte
|
|
29
|
+
services/snapshot.js snapshot, impronta, compressione (funzioni pure)
|
|
30
|
+
services/revisionService.js lettura dello stato, creazione dei record, conservazione
|
|
31
|
+
services/agentWriter.js scritture sull'agente: creazione, sostituzione dei blocchi, operazioni
|
|
32
|
+
services/atomic.js transazione se disponibile
|
|
33
|
+
services/references.js prova a secco: riferimenti esterni mancanti (solo letture)
|
|
34
|
+
listeners.js listener facoltativi: pulizia alla cancellazione, snapshot automatico
|
|
35
|
+
test/aiGenerationsRoute.js test
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## 2. Il record di revisione
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
{
|
|
42
|
+
revision_id: "uuid", // generato dal server
|
|
43
|
+
id_project: "…", id_faq_kb: "…", // sempre dal request, mai dal body
|
|
44
|
+
seq: 3, // ordine nella storia dell'agente (unico per agente)
|
|
45
|
+
kind: "restore", // ai_create | ai_edit | checkpoint | auto | restore | release
|
|
46
|
+
reason: "in_place", // auto: before_ai_edit | before_restore; restore: in_place | as_copy
|
|
47
|
+
note: "Ripristino della revisione 1",
|
|
48
|
+
parent_id: "uuid", // la revisione da cui si parte (per restore: quella ripristinata)
|
|
49
|
+
ai: { // solo ai_create e ai_edit: quello che manda il DS, opaco
|
|
50
|
+
request: { initialPrompt, finalPrompt, finalPromptEdited, interview, assumptions, unsupported },
|
|
51
|
+
result: { blueprint, blueprintVersion, idMap, notes, warnings, model, promptVersion,
|
|
52
|
+
catalogVersion, compilerVersion, changes /* solo ai_edit, calcolato dal server */ }
|
|
53
|
+
},
|
|
54
|
+
snapshot: { encoding: "gzip+json", data: <Buffer>, bytes: 41200, intents: 6 }, // tutte tranne release
|
|
55
|
+
contentHash: "sha256:…", // impronta dei blocchi
|
|
56
|
+
release_id: "…", publishedAt, // solo release
|
|
57
|
+
createdBy: "…", createdAt, updatedAt
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Lo snapshot** ha la forma dell'export (`GET /faq_kb/exportjson/:id`): `name`, `description`, `language`, `webhook_*`, `type`, `subtype`, `attributes` **senza `globals`** (possono contenere segreti; al ripristino restano quelli correnti), e `intents` con i blocchi completi, `intent_id` compresi.
|
|
62
|
+
|
|
63
|
+
**L'impronta** è lo sha256 dei blocchi normalizzati: ordinati per `intent_id`, senza `attributes.position` e `attributes.color`, con le chiavi in ordine. Spostare un blocco sul canvas non la cambia; cambiare un testo sì. Serve a due cose: sapere se l'agente è stato modificato a mano dopo l'ultima revisione, e rifiutare un ripristino o una modifica se l'agente è cambiato rispetto a quello che il client aveva visto (`expectedHash`).
|
|
64
|
+
|
|
65
|
+
## 3. Le rotte
|
|
66
|
+
|
|
67
|
+
Base: `/:projectid/modules/ai-generations`. Tutte filtrano per `id_project = req.projectid`; `createdBy` è sempre `req.user.id`.
|
|
68
|
+
|
|
69
|
+
| Rotta | Body | Risposta |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `GET /` | — | `{ enabled, transactions, max_intents }` |
|
|
72
|
+
| `POST /agents` | `{ agent, ai, note }` — `agent` nel formato dell'export con `intents[]` | 201 `{ bot_id, chatbot, revision }`; 400 body non valido (nomi o id duplicati); 422 blocchi rifiutati dal model, con l'elenco |
|
|
73
|
+
| `GET /agents/:id/revisions?page&limit` | — | `{ items, total, page, limit }`, ordinati per `seq` decrescente, **leggeri** (senza snapshot, `finalPrompt`, `blueprint`) |
|
|
74
|
+
| `POST /agents/:id/revisions` | `{ note }` | 201 `{ revision }` (checkpoint) |
|
|
75
|
+
| `POST /agents/:id/revisions/release` | `{ release_id }` | 201 `{ revision }`; 200 `{ already_linked: true }` se già collegata; 404 se la release non ha `root_id` = id |
|
|
76
|
+
| `GET /agents/:id/hash` | — | `{ contentHash, intents, revision_id, revision_seq, modified }` |
|
|
77
|
+
| `POST /agents/:id/restore` | `{ revision_id \| release_id, as_copy, expectedHash, note, name, dry_run }` | 200 `{ bot_id, revision, backup }`; con `as_copy` 201 con il nuovo `bot_id`; con `dry_run: true` 200 `{ dry_run, source, intents, contentHash, changed, missing[] }` senza scrivere (i riferimenti a dipartimenti, knowledge base, tabelle e agenti che non esistono più); 409 se `expectedHash` non coincide, se l'agente è cestinato, o se la revisione non ha snapshot |
|
|
78
|
+
| `POST /agents/:id/edit` | `{ operations[], ai, expectedHash, note }` — `operations`: `{ type: post\|put\|delete, intent }` | 200 `{ bot_id, revision, backup, changes }`; 404 blocco non trovato; 409 nome duplicato o hash stantio |
|
|
79
|
+
| `DELETE /agents/:id/revisions` | — | `{ deleted }` |
|
|
80
|
+
| `GET /revisions/:revision_id?snapshot=true` | — | La revisione completa; con `snapshot=true` anche `snapshotData` in chiaro |
|
|
81
|
+
|
|
82
|
+
Il ruolo richiesto è `admin`, come per la pubblicazione. Un agente di un altro progetto risponde 404.
|
|
83
|
+
|
|
84
|
+
## 4. Il flusso, passo per passo
|
|
85
|
+
|
|
86
|
+
### Creazione con l'AI (`POST /agents`)
|
|
87
|
+
|
|
88
|
+
1. Validazione del body: nome, blocchi non vuoti, nessun `intent_display_name` o `intent_id` duplicato.
|
|
89
|
+
2. Validazione dei blocchi con il model `faq`, **prima** di creare l'agente: gli errori si fermano qui senza scrivere nulla.
|
|
90
|
+
3. `faqService.create` con `template: 'empty'` (emette `faqbot.create`, come l'import); se `attributes.rules` cita il bot, i partecipanti vengono ripuntati sul nuovo id.
|
|
91
|
+
4. `Faq.insertMany` dei blocchi, atteso; se qualcosa fallisce, blocchi e agente vengono rimossi e la risposta è 422 o 500 con il motivo.
|
|
92
|
+
5. Snapshot e impronta dai blocchi appena salvati; revisione `ai_create`, `seq 1`.
|
|
93
|
+
6. Eventi: `faq.create` per ogni blocco e `faqbot.created`.
|
|
94
|
+
7. Risposta 201. Il DS non deve più rileggere i blocchi per verificarli.
|
|
95
|
+
|
|
96
|
+
### Ripristino in place (`POST /agents/:id/restore`)
|
|
97
|
+
|
|
98
|
+
1. Carica l'agente (404 se non è del progetto, 409 se cestinato) e la sorgente: lo snapshot della revisione, oppure l'export della release indicata (che deve avere `root_id` = id).
|
|
99
|
+
2. Calcola l'impronta corrente; se `expectedHash` è presente e diverso, 409.
|
|
100
|
+
3. Se l'impronta corrente è diversa dall'ultima revisione con snapshot: revisione **`auto` / `before_restore`** con lo stato corrente. Le modifiche a mano non si perdono mai.
|
|
101
|
+
4. Sostituisce i blocchi con gli **stessi `intent_id`**; aggiorna nome, descrizione, lingua e `attributes` (i `globals` correnti restano), `modified: true`.
|
|
102
|
+
5. Revisione `restore` / `in_place`, con `parent_id` = la revisione ripristinata e il nuovo snapshot.
|
|
103
|
+
6. Eventi: `faq.delete` per ogni blocco tolto, `faq.create` per ogni blocco inserito, `faqbot.update`. Sono gli eventi che svuotano la cache Redis per singolo blocco e per agente e avvisano gli iscritti.
|
|
104
|
+
7. Conservazione (§6) e risposta con `revision` e `backup` (null se non è servito).
|
|
105
|
+
|
|
106
|
+
La release pubblicata non cambia: per portare il ripristino in produzione si pubblica di nuovo.
|
|
107
|
+
|
|
108
|
+
### Ripristino come copia (`as_copy: true`)
|
|
109
|
+
|
|
110
|
+
Crea un agente nuovo dallo snapshot (nome a scelta, altrimenti «… (copia)»), con la stessa procedura della creazione, e apre in quello una storia nuova con una revisione `restore` / `as_copy` che rimanda alla revisione d'origine. L'agente di partenza non è toccato.
|
|
111
|
+
|
|
112
|
+
### Modifica via prompt (`POST /agents/:id/edit`)
|
|
113
|
+
|
|
114
|
+
Come il ripristino nei passi 1–3 (con `auto` / `before_ai_edit`), poi applica le operazioni una per una, attese: `post` crea, `put` aggiorna per `intent_id` (o `_id`), `delete` cancella. Un blocco non trovato dà 404, un nome duplicato 409, e senza transazione i blocchi di prima vengono rimessi. La revisione `ai_edit` porta `ai.result.changes = { added, updated, removed }` con gli `intent_id`, calcolato dal server.
|
|
115
|
+
|
|
116
|
+
### Pubblicazione
|
|
117
|
+
|
|
118
|
+
Il DS pubblica come oggi (`PUT /faq_kb/:id/publish` restituisce il `bot_id` della release) e poi chiama `POST /agents/:id/revisions/release`. La revisione `release` non ha snapshot: la release è già una copia completa, e si può usare come sorgente di un ripristino.
|
|
119
|
+
|
|
120
|
+
## 5. Atomicità
|
|
121
|
+
|
|
122
|
+
| Situazione | Strategia |
|
|
123
|
+
|---|---|
|
|
124
|
+
| **Replica set** (Atlas in produzione e stage): rilevato all'avvio con `hello` → `setName` | Ripristino e modifica girano in `session.withTransaction`: cancellazione, inserimento, aggiornamento dell'agente e revisione riescono o falliscono insieme. Gli eventi si emettono dopo il commit |
|
|
125
|
+
| **Senza replica set** (sviluppo, `mongodb-runner`) | **Staging e scambio**: i blocchi da ripristinare si inseriscono prima sotto un id di staging (`<id>~restore~<timestamp>`), dove validazione e indici unici possono fallire senza toccare la bozza; poi si cancellano i blocchi correnti e si sposta lo staging sull'agente con un `updateMany`. Se lo spostamento fallisce, i blocchi di prima vengono rimessi |
|
|
126
|
+
| **Sempre** | La revisione `auto` prima di qualunque scrittura è la rete di sicurezza: è consultabile e ripristinabile a sua volta |
|
|
127
|
+
| **Creazione** | Validazione prima di scrivere; se l'inserimento fallisce, blocchi e agente vengono rimossi (compreso il documento residuo che il listener di tilebot può ricreare con il suo upsert asincrono dell'url) |
|
|
128
|
+
|
|
129
|
+
## 6. Conservazione
|
|
130
|
+
|
|
131
|
+
A ogni checkpoint, ripristino o modifica: si tengono tutte le `ai_create`, `ai_edit` e `release`; le ultime 20 `checkpoint`; le `auto` degli ultimi 30 giorni; in tutto al più 50 record per agente, togliendo prima le `checkpoint`, `auto` e `restore` più vecchie. L'ultima revisione non si tocca mai. `DELETE /agents/:id/revisions` cancella la storia; i record di agenti eliminati fuori dal DS restano orfani e innocui.
|
|
132
|
+
|
|
133
|
+
## 7. Test
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# solo il modulo (Redis e MongoDB locali; il .env di sviluppo si può sostituire con DOTENV_PATH)
|
|
137
|
+
AI_GENERATIONS_ENABLED=true NODE_ENV=test npx mocha pubmodules/aiGenerations/test/*.js --exit
|
|
138
|
+
|
|
139
|
+
# ramo a modulo spento: le rotte rispondono 404
|
|
140
|
+
AI_GENERATIONS_ENABLED=false NODE_ENV=test npx mocha pubmodules/aiGenerations/test/*.js --exit
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Nella suite completa (`npm test`) l'app viene caricata dai primi file: il modulo è montato solo se `AI_GENERATIONS_ENABLED=true` è già nell'ambiente; altrimenti il test verifica il ramo a modulo spento. I test (21) coprono: creazione atomica e rollback, validazione, impronta (posizioni ignorate), checkpoint e lista leggera, isolamento fra progetti, ripristino in place con backup automatico e reversibilità, globals conservati, copia, release (collegamento, rifiuto di una release estranea, ripristino da release), agente cestinato, prova a secco, modifica con `changes`, hash stantio, cancellazione della storia, listener di pulizia e di snapshot automatico, snapshot e impronta.
|
|
144
|
+
|
|
145
|
+
## 8. Fase F: prova a secco, pulizia, snapshot automatico
|
|
146
|
+
|
|
147
|
+
- **Prova a secco** (`POST /agents/:id/restore` con `dry_run: true`): nessuna scrittura; risponde con i riferimenti dello snapshot che nel progetto non esistono più (`kind`: `department`, `knowledge_base`, `data_table`, `chatbot`, con il valore e il nome del blocco) e con l'impronta corrente. Il DS la chiama prima di ogni ripristino e lo dice all'utente.
|
|
148
|
+
- **Pulizia** (`AI_GENERATIONS_CLEANUP_ENABLED=true`): la storia si cancella con l'agente, anche quando la cancellazione non passa dal DS.
|
|
149
|
+
- **Snapshot automatico** (`AI_GENERATIONS_AUTOSNAPSHOT_ENABLED=true`): un timer per agente (debounce) dopo gli eventi dei blocchi; allo scadere, se l'impronta dei blocchi è diversa dall'ultima revisione e l'ultima revisione non è appena stata scritta dal modulo stesso, si salva una revisione `auto` / `manual_changes` (`createdBy: system`). Mai sulle release, mai sugli id di staging, mai senza una storia già esistente.
|
|
150
|
+
|
|
151
|
+
## 9. Cosa non c'è ancora
|
|
152
|
+
|
|
153
|
+
- L'agente **forkato o pubblico** in un altro progetto non porta con sé la storia.
|
|
154
|
+
- Le transazioni sono provate solo con il rilevamento: su un replica set locale il ramo `withTransaction` va collaudato.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const aiGenerationsRoute = require('./routes/aiGenerations');
|
|
2
|
+
const listeners = require('./listeners');
|
|
3
|
+
|
|
4
|
+
// Listener facoltativi (pulizia alla cancellazione, snapshot automatico): ognuno dietro la sua variabile.
|
|
5
|
+
listeners.register();
|
|
6
|
+
|
|
7
|
+
module.exports = { aiGenerationsRoute, listeners };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listener facoltativi del modulo (fase F), ognuno dietro la sua variabile d'ambiente:
|
|
3
|
+
*
|
|
4
|
+
* - AI_GENERATIONS_CLEANUP_ENABLED=true: alla cancellazione di un agente (`faqbot.delete`, `faqbot.deleted`)
|
|
5
|
+
* si cancella la sua storia, anche se la cancellazione non passa dal DS.
|
|
6
|
+
* - AI_GENERATIONS_AUTOSNAPSHOT_ENABLED=true: dopo modifiche a mano ai blocchi (`faq.create`, `faq.update`,
|
|
7
|
+
* `faq.delete`) si salva una revisione `auto` / `manual_changes`, al più una ogni
|
|
8
|
+
* AI_GENERATIONS_AUTOSNAPSHOT_DEBOUNCE_MS (default 5 minuti) per agente, e solo se i blocchi sono
|
|
9
|
+
* davvero cambiati rispetto all'ultima revisione.
|
|
10
|
+
*
|
|
11
|
+
* Con le variabili spente non si registra nulla.
|
|
12
|
+
*/
|
|
13
|
+
const winston = require('../../config/winston');
|
|
14
|
+
const botEvent = require('../../event/botEvent');
|
|
15
|
+
const faqBotEvent = require('../../event/faqBotEvent');
|
|
16
|
+
const Faq_kb = require('../../models/faq_kb');
|
|
17
|
+
const revisionService = require('./services/revisionService');
|
|
18
|
+
|
|
19
|
+
const DEBOUNCE_MS = Number(process.env.AI_GENERATIONS_AUTOSNAPSHOT_DEBOUNCE_MS) || 5 * 60 * 1000;
|
|
20
|
+
/** Una revisione più recente di così è opera del modulo (ripristino, modifica): non è una modifica a mano. */
|
|
21
|
+
const RECENT_MS = Number(process.env.AI_GENERATIONS_AUTOSNAPSHOT_RECENT_MS) || DEBOUNCE_MS;
|
|
22
|
+
const pending = new Map();
|
|
23
|
+
|
|
24
|
+
function botIdOf(payload) {
|
|
25
|
+
const bot = payload && (payload.chatbot || payload);
|
|
26
|
+
const id = bot && (bot._id || bot.id);
|
|
27
|
+
return id ? String(id) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cleanup(payload) {
|
|
31
|
+
const id_faq_kb = botIdOf(payload);
|
|
32
|
+
if (!id_faq_kb) return;
|
|
33
|
+
try {
|
|
34
|
+
const bot = payload && (payload.chatbot || payload);
|
|
35
|
+
const id_project = bot && bot.id_project ? String(bot.id_project) : null;
|
|
36
|
+
const filter = id_project ? { id_project: id_project, id_faq_kb: id_faq_kb } : { id_faq_kb: id_faq_kb };
|
|
37
|
+
const result = await revisionService.deleteByFilter(filter);
|
|
38
|
+
if (result) winston.debug('(aiGenerations) cleanup: deleted ' + result + ' revisions of ' + id_faq_kb);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
winston.error('(aiGenerations) cleanup failed for ' + id_faq_kb + ': ' + err.message);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function snapshot(id_faq_kb) {
|
|
45
|
+
pending.delete(id_faq_kb);
|
|
46
|
+
try {
|
|
47
|
+
const kb = await Faq_kb.findById(id_faq_kb).exec();
|
|
48
|
+
if (!kb || kb.trashed === true || kb.root_id) return; // mai sulle release
|
|
49
|
+
const state = await revisionService.currentState(kb);
|
|
50
|
+
const last = await revisionService.lastRevision(String(kb.id_project), id_faq_kb, { contentHash: { $ne: null } });
|
|
51
|
+
if (!last || last.contentHash === state.contentHash) return; // niente storia: niente snapshot automatici; niente cambiamenti: niente
|
|
52
|
+
// Una revisione appena scritta: i blocchi sono cambiati per un ripristino o una modifica del modulo, non a mano
|
|
53
|
+
if (Date.now() - new Date(last.createdAt).getTime() < RECENT_MS) return;
|
|
54
|
+
await revisionService.create({
|
|
55
|
+
id_project: String(kb.id_project),
|
|
56
|
+
id_faq_kb: kb._id,
|
|
57
|
+
kind: 'auto',
|
|
58
|
+
reason: 'manual_changes',
|
|
59
|
+
note: 'Modifiche a mano dopo la revisione ' + last.seq,
|
|
60
|
+
parent_id: last.revision_id,
|
|
61
|
+
snapshot: state.snapshot,
|
|
62
|
+
contentHash: state.contentHash,
|
|
63
|
+
createdBy: 'system'
|
|
64
|
+
});
|
|
65
|
+
await revisionService.prune(String(kb.id_project), id_faq_kb);
|
|
66
|
+
winston.debug('(aiGenerations) autosnapshot saved for ' + id_faq_kb);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
winston.error('(aiGenerations) autosnapshot failed for ' + id_faq_kb + ': ' + err.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function schedule(faq) {
|
|
73
|
+
const id_faq_kb = faq && faq.id_faq_kb ? String(faq.id_faq_kb) : null;
|
|
74
|
+
if (!id_faq_kb || id_faq_kb.indexOf('~') !== -1) return; // niente sugli id di staging
|
|
75
|
+
if (pending.has(id_faq_kb)) return;
|
|
76
|
+
const timer = setTimeout(() => snapshot(id_faq_kb), DEBOUNCE_MS);
|
|
77
|
+
if (timer.unref) timer.unref();
|
|
78
|
+
pending.set(id_faq_kb, timer);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function register() {
|
|
82
|
+
const registered = [];
|
|
83
|
+
if (process.env.AI_GENERATIONS_CLEANUP_ENABLED === 'true') {
|
|
84
|
+
botEvent.on('faqbot.delete', cleanup);
|
|
85
|
+
botEvent.on('faqbot.deleted', cleanup);
|
|
86
|
+
registered.push('cleanup');
|
|
87
|
+
}
|
|
88
|
+
if (process.env.AI_GENERATIONS_AUTOSNAPSHOT_ENABLED === 'true') {
|
|
89
|
+
['faq.create', 'faq.update', 'faq.delete'].forEach(name => faqBotEvent.on(name, schedule));
|
|
90
|
+
registered.push('autosnapshot');
|
|
91
|
+
}
|
|
92
|
+
if (registered.length) winston.info('(aiGenerations) listeners: ' + registered.join(', '));
|
|
93
|
+
return registered;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { register, cleanup, snapshot, schedule, DEBOUNCE_MS, RECENT_MS, pendingCount: () => pending.size, isPending: (id) => pending.has(String(id)) };
|