@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/app.js +6 -35
  3. package/config/native_mcp/native_mcp_servers.json +7 -0
  4. package/controllers/chatbot.controller.js +659 -0
  5. package/controllers/intents.controller.js +139 -0
  6. package/errorCodes.js +2 -1
  7. package/event/botEvent.js +43 -3
  8. package/middleware/errorHandler.js +41 -0
  9. package/models/chatbotTemplates.js +4 -0
  10. package/models/faq.js +10 -0
  11. package/models/faq_kb.js +49 -0
  12. package/package.json +2 -2
  13. package/pubmodules/aiGenerations/README.md +154 -0
  14. package/pubmodules/aiGenerations/index.js +7 -0
  15. package/pubmodules/aiGenerations/listeners.js +96 -0
  16. package/pubmodules/aiGenerations/models/faqKbRevision.js +51 -0
  17. package/pubmodules/aiGenerations/routes/aiGenerations.js +426 -0
  18. package/pubmodules/aiGenerations/services/agentWriter.js +286 -0
  19. package/pubmodules/aiGenerations/services/atomic.js +70 -0
  20. package/pubmodules/aiGenerations/services/references.js +78 -0
  21. package/pubmodules/aiGenerations/services/revisionService.js +169 -0
  22. package/pubmodules/aiGenerations/services/snapshot.js +109 -0
  23. package/pubmodules/aiGenerations/test/aiGenerationsRoute.js +503 -0
  24. package/pubmodules/pubModulesManager.js +17 -0
  25. package/routes/faq.js +10 -3
  26. package/routes/faq_kb.js +716 -411
  27. package/services/chatbotService.js +84 -5
  28. package/services/faqService.js +159 -45
  29. package/services/v3FlowService.js +416 -0
  30. package/template/chatbot/blank.js +21 -3
  31. package/template/chatbot/blank_subagent.js +139 -0
  32. package/template/chatbot/blank_voice.js +21 -4
  33. package/template/chatbot/blank_voice_twilio.js +21 -5
  34. package/template/chatbot/index.js +1 -0
  35. package/template/chatbot/subagent_notes.js +30 -0
  36. package/test/faqRoute.js +83 -0
  37. package/test/faqkbRoute.js +982 -523
  38. package/test/faqkbV3Route.js +436 -0
  39. package/test/fixtures/example-json-with-subagents.txt +1 -0
  40. package/test/nativemcp.test.js +6 -1
  41. package/test/v3FlowService.js +187 -0
  42. package/test/webhookRoute.js +2 -1
  43. package/utils/AppError.js +9 -0
  44. package/utils/asyncHandler.js +10 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Le scritture sull'agente: creazione, sostituzione dei blocchi, operazioni sui blocchi.
3
+ * Usa i model e il servizio esistenti (`faq_kb`, `faq`, `faqService`) senza modificarli.
4
+ *
5
+ * Gli eventi (`faqbot.*`, `faq.*`) NON vengono emessi qui: chi chiama li emette dopo che
6
+ * la scrittura è andata a buon fine (dopo il commit, con le transazioni), con `emitEvents`.
7
+ */
8
+ const winston = require('../../../config/winston');
9
+ const Faq_kb = require('../../../models/faq_kb');
10
+ const Faq = require('../../../models/faq');
11
+ const faqService = require('../../../services/faqService');
12
+ const botEvent = require('../../../event/botEvent');
13
+ const faqBotEvent = require('../../../event/faqBotEvent');
14
+
15
+ class WriteError extends Error {
16
+ constructor(status, message, details) {
17
+ super(message);
18
+ this.status = status;
19
+ this.details = details;
20
+ }
21
+ }
22
+
23
+ /** Il documento `faq` da un blocco nel formato dell'export (come importjson). */
24
+ function intentDoc(intent, ctx) {
25
+ return {
26
+ id_faq_kb: String(ctx.id_faq_kb),
27
+ id_project: ctx.id_project,
28
+ createdBy: ctx.createdBy,
29
+ intent_display_name: intent.intent_display_name,
30
+ intent_id: intent.intent_id,
31
+ question: intent.question,
32
+ answer: intent.answer,
33
+ reply: intent.reply,
34
+ form: intent.form,
35
+ enabled: intent.enabled,
36
+ webhook_enabled: intent.webhook_enabled,
37
+ language: intent.language,
38
+ actions: intent.actions,
39
+ attributes: intent.attributes,
40
+ agents_available: intent.agents_available
41
+ };
42
+ }
43
+
44
+ /** Le regole in `attributes` che citano il bot come partecipante vanno ripuntate sul nuovo id (come importjson). */
45
+ function rewriteRules(attributes, bot_id) {
46
+ if (!attributes || !Array.isArray(attributes.rules)) return attributes;
47
+ attributes.rules.forEach(rule => {
48
+ const message = rule && rule.do && rule.do[0] && rule.do[0].message;
49
+ if (message && Array.isArray(message.participants) && message.participants.length) {
50
+ message.participants[0] = 'bot_' + bot_id;
51
+ }
52
+ });
53
+ return attributes;
54
+ }
55
+
56
+ /** Gli errori di un insertMany non ordinato, in forma leggibile. */
57
+ function bulkErrors(err) {
58
+ const writeErrors = (err && (err.writeErrors || (err.result && err.result.getWriteErrors && err.result.getWriteErrors()))) || [];
59
+ if (!writeErrors.length) return [{ message: err && err.message }];
60
+ return writeErrors.map(e => ({
61
+ index: e.index,
62
+ code: e.code,
63
+ message: e.errmsg || e.message,
64
+ intent_display_name: e.err && e.err.op && e.err.op.intent_display_name
65
+ }));
66
+ }
67
+
68
+ class AgentWriter {
69
+
70
+ /**
71
+ * Crea un agente e i suoi blocchi. Se un blocco fallisce, cancella tutto e lancia un WriteError.
72
+ * Restituisce { kb, faqs }.
73
+ */
74
+ async createAgent(id_project, createdBy, agent) {
75
+ const data = {
76
+ name: agent.name,
77
+ description: agent.description,
78
+ language: agent.language || 'en',
79
+ type: agent.type || 'tilebot',
80
+ subtype: agent.subtype || 'chatbot',
81
+ webhook_enabled: !!agent.webhook_enabled,
82
+ webhook_url: agent.webhook_url,
83
+ mainCategory: agent.mainCategory,
84
+ intentsEngine: agent.intentsEngine,
85
+ template: 'empty',
86
+ attributes: agent.attributes || {}
87
+ };
88
+
89
+ // I blocchi si validano PRIMA di creare l'agente: quasi tutti gli errori si fermano qui,
90
+ // senza scrivere nulla.
91
+ await this.validateBlocks(agent.intents || [], { id_faq_kb: 'validation', id_project: id_project, createdBy: createdBy });
92
+
93
+ let kb;
94
+ try {
95
+ kb = await faqService.create(id_project, createdBy, data);
96
+ } catch (err) {
97
+ throw new WriteError(500, 'Error creating chatbot', { error: String(err) });
98
+ }
99
+
100
+ try {
101
+ if (data.attributes && Array.isArray(data.attributes.rules) && data.attributes.rules.length) {
102
+ rewriteRules(data.attributes, kb._id);
103
+ kb = await Faq_kb.findByIdAndUpdate(kb._id, { attributes: data.attributes }, { new: true }).exec();
104
+ }
105
+ const faqs = await this.insertBlocks(String(kb._id), id_project, createdBy, agent.intents || [], null);
106
+ return { kb: kb, faqs: faqs };
107
+ } catch (err) {
108
+ winston.error('(aiGenerations) createAgent failed, removing chatbot ' + kb._id + ': ' + err.message);
109
+ await this.removeAgent(kb._id);
110
+ if (err instanceof WriteError) throw err;
111
+ throw new WriteError(500, 'Error creating chatbot blocks', { error: err.message });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Rimuove un agente appena creato e i suoi blocchi (rollback della creazione).
117
+ * Il listener di tilebot su `faqbot.create` aggiorna l'url con un upsert asincrono: se arriva dopo
118
+ * la cancellazione, lascia un documento con il solo url. Una seconda pulizia, poco dopo, toglie
119
+ * quel documento, riconoscibile perché senza nome.
120
+ */
121
+ async removeAgent(kb_id) {
122
+ await Faq.deleteMany({ id_faq_kb: String(kb_id) }).exec().catch(() => null);
123
+ await Faq_kb.findByIdAndDelete(kb_id).exec().catch(() => null);
124
+ setTimeout(() => {
125
+ Faq_kb.deleteOne({ _id: kb_id, name: { $exists: false } }).exec()
126
+ .then(result => { if (result && result.deletedCount) winston.debug('(aiGenerations) removed upsert leftover of ' + kb_id); })
127
+ .catch(() => null);
128
+ }, 2000).unref();
129
+ }
130
+
131
+ /**
132
+ * Validazione esplicita dei blocchi con il model: con `ordered: false` mongoose salterebbe in
133
+ * silenzio i documenti non validi. Qui si rifiuta tutto, e si dice quali blocchi e perché.
134
+ */
135
+ async validateBlocks(intents, ctx) {
136
+ const invalid = [];
137
+ await Promise.all(intents.map((intent, index) => {
138
+ const doc = intentDoc(intent, ctx);
139
+ return new Faq(doc).validate().catch(err => {
140
+ invalid.push({ index: index, intent_display_name: doc.intent_display_name, message: err.message });
141
+ });
142
+ }));
143
+ if (invalid.length) {
144
+ invalid.sort((a, b) => a.index - b.index);
145
+ throw new WriteError(422, 'Some blocks are not valid', { blocks: invalid });
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Inserisce i blocchi sotto `id_faq_kb`. Non ordinato: si sa esattamente quali sono falliti.
151
+ * Se uno fallisce, i blocchi inseriti da questa chiamata vengono tolti e si lancia un WriteError 422.
152
+ */
153
+ async insertBlocks(id_faq_kb, id_project, createdBy, intents, session) {
154
+ if (!intents.length) return [];
155
+ const ctx = { id_faq_kb: id_faq_kb, id_project: id_project, createdBy: createdBy };
156
+ await this.validateBlocks(intents, ctx);
157
+ const docs = intents.map(intent => intentDoc(intent, ctx));
158
+
159
+ const options = { ordered: false };
160
+ if (session) options.session = session;
161
+ try {
162
+ const saved = await Faq.insertMany(docs, options);
163
+ if (saved.length !== docs.length) {
164
+ throw new Error('inserted ' + saved.length + ' of ' + docs.length + ' blocks');
165
+ }
166
+ return saved.map(doc => (typeof doc.toObject === 'function' ? doc.toObject() : doc));
167
+ } catch (err) {
168
+ const errors = bulkErrors(err);
169
+ winston.error('(aiGenerations) insertBlocks failed for ' + id_faq_kb + ': ' + JSON.stringify(errors));
170
+ if (!session) {
171
+ await Faq.deleteMany({ id_faq_kb: id_faq_kb }).exec().catch(() => null);
172
+ }
173
+ throw new WriteError(422, 'Some blocks could not be saved', { blocks: errors });
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Sostituisce i blocchi dell'agente con quelli dello snapshot, con gli stessi intent_id.
179
+ * - Con la sessione (transazione): cancella e inserisce nella stessa transazione.
180
+ * - Senza: inserisce prima in staging (validazione e indici falliscono lì, senza toccare la bozza),
181
+ * poi cancella i blocchi correnti e sposta lo staging sull'agente. Se lo spostamento fallisce,
182
+ * rimette i blocchi di prima.
183
+ * Restituisce { removed, inserted } con i documenti, per gli eventi.
184
+ */
185
+ async replaceBlocks(kb, snapshot, id_project, createdBy, session) {
186
+ const id_faq_kb = String(kb._id);
187
+ const current = await Faq.find({ id_faq_kb: id_faq_kb }).session(session || null).lean().exec();
188
+
189
+ if (session) {
190
+ await Faq.deleteMany({ id_faq_kb: id_faq_kb }).session(session).exec();
191
+ const inserted = await this.insertBlocks(id_faq_kb, id_project, createdBy, snapshot.intents || [], session);
192
+ return { removed: current, inserted: inserted };
193
+ }
194
+
195
+ const staging = id_faq_kb + '~restore~' + Date.now();
196
+ await this.insertBlocks(staging, id_project, createdBy, snapshot.intents || [], null);
197
+
198
+ await Faq.deleteMany({ id_faq_kb: id_faq_kb }).exec();
199
+ try {
200
+ await Faq.updateMany({ id_faq_kb: staging }, { $set: { id_faq_kb: id_faq_kb } }).exec();
201
+ } catch (err) {
202
+ winston.error('(aiGenerations) replaceBlocks swap failed for ' + id_faq_kb + ', restoring previous blocks: ' + err.message);
203
+ await Faq.deleteMany({ id_faq_kb: { $in: [id_faq_kb, staging] } }).exec().catch(() => null);
204
+ if (current.length) await Faq.insertMany(current, { ordered: false }).catch(e => winston.error('(aiGenerations) recovery failed: ' + e.message));
205
+ throw new WriteError(500, 'Error replacing blocks, previous blocks restored', { error: err.message });
206
+ }
207
+ const inserted = await Faq.find({ id_faq_kb: id_faq_kb }).lean().exec();
208
+ return { removed: current, inserted: inserted };
209
+ }
210
+
211
+ /** Aggiorna i campi dell'agente dallo snapshot. I `globals` correnti restano. */
212
+ async updateAgentFromSnapshot(kb, snapshot, session) {
213
+ const currentAttributes = (kb.attributes && typeof kb.attributes === 'object') ? kb.attributes : {};
214
+ const attributes = Object.assign({}, snapshot.attributes || {});
215
+ if (currentAttributes.globals !== undefined) attributes.globals = currentAttributes.globals;
216
+ rewriteRules(attributes, kb._id);
217
+ const update = { attributes: attributes, modified: true };
218
+ ['name', 'description', 'language', 'webhook_enabled', 'webhook_url'].forEach(field => {
219
+ if (snapshot[field] !== undefined) update[field] = snapshot[field];
220
+ });
221
+ const options = { new: true };
222
+ if (session) options.session = session;
223
+ return Faq_kb.findByIdAndUpdate(kb._id, update, options).exec();
224
+ }
225
+
226
+ async setModified(kb_id, session) {
227
+ const options = { new: true };
228
+ if (session) options.session = session;
229
+ return Faq_kb.findByIdAndUpdate(kb_id, { modified: true }, options).exec();
230
+ }
231
+
232
+ /**
233
+ * Applica operazioni sui blocchi (come `ops_update`, ma attese e con gli errori riportati):
234
+ * `{ type: 'post' | 'put' | 'delete', intent }`. `put` e `delete` trovano il blocco per `intent_id`
235
+ * (o per `_id`). Restituisce { created, updated, deleted } con i documenti.
236
+ */
237
+ async applyOperations(kb, operations, id_project, createdBy, session) {
238
+ const id_faq_kb = String(kb._id);
239
+ const result = { created: [], updated: [], deleted: [] };
240
+ const options = session ? { session: session } : {};
241
+
242
+ for (let i = 0; i < operations.length; i++) {
243
+ const op = operations[i] || {};
244
+ const intent = op.intent || {};
245
+ const selector = intent.intent_id ? { id_faq_kb: id_faq_kb, intent_id: intent.intent_id } : { id_faq_kb: id_faq_kb, _id: intent._id };
246
+ try {
247
+ if (op.type === 'post') {
248
+ const created = await Faq.create([intentDoc(intent, { id_faq_kb: id_faq_kb, id_project: id_project, createdBy: createdBy })], options);
249
+ result.created.push(created[0].toObject());
250
+ } else if (op.type === 'put') {
251
+ const fields = intentDoc(intent, { id_faq_kb: id_faq_kb, id_project: id_project, createdBy: createdBy });
252
+ delete fields.createdBy;
253
+ Object.keys(fields).forEach(key => { if (fields[key] === undefined) delete fields[key]; });
254
+ const updated = await Faq.findOneAndUpdate(selector, { $set: fields }, Object.assign({ new: true }, options)).exec();
255
+ if (!updated) throw new WriteError(404, 'Block not found for put', { index: i, intent_id: intent.intent_id });
256
+ result.updated.push(updated.toObject());
257
+ } else if (op.type === 'delete') {
258
+ const deleted = await Faq.findOneAndDelete(selector, options).exec();
259
+ if (!deleted) throw new WriteError(404, 'Block not found for delete', { index: i, intent_id: intent.intent_id });
260
+ result.deleted.push(deleted.toObject());
261
+ } else {
262
+ throw new WriteError(400, 'Unknown operation type', { index: i, type: op.type });
263
+ }
264
+ } catch (err) {
265
+ if (err instanceof WriteError) throw err;
266
+ const status = err && err.code === 11000 ? 409 : 500;
267
+ throw new WriteError(status, 'Operation ' + i + ' (' + op.type + ') failed', { index: i, error: err.message });
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+
273
+ /** Emette gli eventi che svuotano la cache e avvisano gli iscritti, come fanno le rotte esistenti. */
274
+ emitEvents(events) {
275
+ (events.deletedFaqs || []).forEach(faq => faqBotEvent.emit('faq.delete', faq));
276
+ (events.createdFaqs || []).forEach(faq => faqBotEvent.emit('faq.create', faq));
277
+ (events.updatedFaqs || []).forEach(faq => faqBotEvent.emit('faq.update', faq));
278
+ if (events.updatedBot) botEvent.emit('faqbot.update', events.updatedBot);
279
+ if (events.createdBot) botEvent.emit('faqbot.created', events.createdBot);
280
+ }
281
+ }
282
+
283
+ module.exports = new AgentWriter();
284
+ module.exports.AgentWriter = AgentWriter;
285
+ module.exports.WriteError = WriteError;
286
+ module.exports.intentDoc = intentDoc;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Esecuzione atomica dove il database lo consente.
3
+ *
4
+ * Con MongoDB in replica set (Atlas in produzione e stage) le scritture di una operazione
5
+ * girano in una transazione: riescono o falliscono insieme. Senza replica set (sviluppo,
6
+ * test con mongodb-runner) la funzione gira senza sessione e chi la usa applica la strategia
7
+ * di staging e scambio, con la revisione automatica come rete di sicurezza.
8
+ *
9
+ * `AI_GENERATIONS_TRANSACTIONS=false` spegne le transazioni anche in replica set;
10
+ * `=true` le forza (utile per provare il ramo su un replica set locale).
11
+ */
12
+ const mongoose = require('mongoose');
13
+ const winston = require('../../../config/winston');
14
+
15
+ let supported = null;
16
+
17
+ async function detect() {
18
+ const forced = process.env.AI_GENERATIONS_TRANSACTIONS;
19
+ if (forced === 'true') return true;
20
+ if (forced === 'false') return false;
21
+ try {
22
+ const admin = mongoose.connection.db.admin();
23
+ let info;
24
+ try {
25
+ info = await admin.command({ hello: 1 });
26
+ } catch (e) {
27
+ info = await admin.command({ isMaster: 1 });
28
+ }
29
+ return !!(info && (info.setName || info.msg === 'isdbgrid'));
30
+ } catch (err) {
31
+ winston.warn('(aiGenerations) unable to detect replica set, transactions disabled: ' + err.message);
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /** True se le transazioni sono disponibili. Il risultato è calcolato una volta sola. */
37
+ async function supportsTransactions() {
38
+ if (supported === null) {
39
+ supported = await detect();
40
+ winston.info('(aiGenerations) transactions ' + (supported ? 'enabled' : 'disabled'));
41
+ }
42
+ return supported;
43
+ }
44
+
45
+ /** Per i test: azzera il rilevamento. */
46
+ function reset() {
47
+ supported = null;
48
+ }
49
+
50
+ /**
51
+ * Esegue `fn(session)`. Con le transazioni disponibili `session` è una sessione in transazione,
52
+ * altrimenti è null. Il valore restituito da `fn` è il valore restituito da `run`.
53
+ */
54
+ async function run(fn) {
55
+ if (!(await supportsTransactions())) {
56
+ return fn(null);
57
+ }
58
+ const session = await mongoose.startSession();
59
+ let result;
60
+ try {
61
+ await session.withTransaction(async () => {
62
+ result = await fn(session);
63
+ });
64
+ } finally {
65
+ session.endSession();
66
+ }
67
+ return result;
68
+ }
69
+
70
+ module.exports = { run, supportsTransactions, reset };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Prova a secco del ripristino: i riferimenti esterni di uno snapshot che nel progetto non esistono più.
3
+ * Dipartimenti (per nome), knowledge base (per id), tabelle (per id) e altri agenti (per id).
4
+ * Solo letture.
5
+ */
6
+ const mongoose = require('mongoose');
7
+ const Department = require('../../../models/department');
8
+ const { Namespace } = require('../../../models/kb_setting');
9
+ const { Table } = require('../../../models/dataTable');
10
+ const Faq_kb = require('../../../models/faq_kb');
11
+
12
+ const isObjectId = (id) => typeof id === 'string' && /^[0-9a-fA-F]{24}$/.test(id) && mongoose.Types.ObjectId.isValid(id);
13
+
14
+ /** I riferimenti esterni dei blocchi dello snapshot, per tipo. */
15
+ function collectReferences(snapshot) {
16
+ const refs = { department: [], knowledge_base: [], data_table: [], chatbot: [] };
17
+ (snapshot && Array.isArray(snapshot.intents) ? snapshot.intents : []).forEach(intent => {
18
+ const name = intent.intent_display_name;
19
+ (Array.isArray(intent.actions) ? intent.actions : []).forEach(action => {
20
+ if (!action || typeof action !== 'object') return;
21
+ switch (action._tdActionType) {
22
+ case 'department':
23
+ if (action.depName) refs.department.push({ value: String(action.depName), intent_display_name: name });
24
+ break;
25
+ case 'askgptv2':
26
+ case 'add_kb_content':
27
+ if (action.namespace && !action.namespaceAsName) refs.knowledge_base.push({ value: String(action.namespace), intent_display_name: name });
28
+ break;
29
+ case 'data_table':
30
+ if (action.tableId) refs.data_table.push({ value: String(action.tableId), intent_display_name: name });
31
+ break;
32
+ case 'replacebotv3':
33
+ if (action.botId && !action.useSlug) refs.chatbot.push({ value: String(action.botId), intent_display_name: name });
34
+ break;
35
+ default:
36
+ break;
37
+ }
38
+ });
39
+ });
40
+ return refs;
41
+ }
42
+
43
+ /**
44
+ * @returns {Promise<Array<{ kind: string, value: string, intent_display_name: string }>>} i riferimenti mancanti
45
+ */
46
+ async function missingReferences(snapshot, id_project) {
47
+ const refs = collectReferences(snapshot);
48
+ const missing = [];
49
+ const values = (list) => Array.from(new Set(list.map(r => r.value)));
50
+
51
+ if (refs.department.length) {
52
+ const names = values(refs.department);
53
+ const found = await Department.find({ id_project: id_project, name: { $in: names } }, { name: 1 }).lean().exec();
54
+ const ok = new Set(found.map(d => d.name));
55
+ refs.department.filter(r => !ok.has(r.value)).forEach(r => missing.push({ kind: 'department', ...r }));
56
+ }
57
+ if (refs.knowledge_base.length) {
58
+ const ids = values(refs.knowledge_base);
59
+ const found = await Namespace.find({ id_project: id_project, id: { $in: ids } }, { id: 1 }).lean().exec();
60
+ const ok = new Set(found.map(n => String(n.id)));
61
+ refs.knowledge_base.filter(r => !ok.has(r.value)).forEach(r => missing.push({ kind: 'knowledge_base', ...r }));
62
+ }
63
+ if (refs.data_table.length) {
64
+ const ids = values(refs.data_table).filter(isObjectId);
65
+ const found = ids.length ? await Table.find({ id_project: id_project, _id: { $in: ids } }, { _id: 1 }).lean().exec() : [];
66
+ const ok = new Set(found.map(t => String(t._id)));
67
+ refs.data_table.filter(r => !ok.has(r.value)).forEach(r => missing.push({ kind: 'data_table', ...r }));
68
+ }
69
+ if (refs.chatbot.length) {
70
+ const ids = values(refs.chatbot).filter(isObjectId);
71
+ const found = ids.length ? await Faq_kb.find({ id_project: id_project, _id: { $in: ids }, trashed: { $ne: true } }, { _id: 1 }).lean().exec() : [];
72
+ const ok = new Set(found.map(b => String(b._id)));
73
+ refs.chatbot.filter(r => !ok.has(r.value)).forEach(r => missing.push({ kind: 'chatbot', ...r }));
74
+ }
75
+ return missing;
76
+ }
77
+
78
+ module.exports = { collectReferences, missingReferences };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Le revisioni: lettura dello stato corrente di un agente, creazione dei record, conservazione.
3
+ * Scrive solo su `faq_kb_revisions`; legge `faq_kbs` e `faqs`.
4
+ */
5
+ const uuidv4 = require('uuid/v4');
6
+ const winston = require('../../../config/winston');
7
+ const Faq_kb = require('../../../models/faq_kb');
8
+ const Faq = require('../../../models/faq');
9
+ const FaqKbRevision = require('../models/faqKbRevision');
10
+ const snapshotUtil = require('./snapshot');
11
+
12
+ const MAX_PER_AGENT = Number(process.env.AI_GENERATIONS_MAX_PER_AGENT) || 50;
13
+ const MAX_CHECKPOINTS = Number(process.env.AI_GENERATIONS_MAX_CHECKPOINTS) || 20;
14
+ const AUTO_TTL_DAYS = Number(process.env.AI_GENERATIONS_AUTO_TTL_DAYS) || 30;
15
+
16
+ /** Proiezione leggera per le liste: niente snapshot, prompt finale e Blueprint. */
17
+ const LIGHT_PROJECTION = { 'snapshot.data': 0, 'ai.request.finalPrompt': 0, 'ai.result.blueprint': 0 };
18
+
19
+ class RevisionService {
20
+
21
+ /** L'agente del progetto, oppure null. */
22
+ async getAgent(id_project, id_faq_kb, session) {
23
+ const query = Faq_kb.findOne({ _id: id_faq_kb, id_project: id_project });
24
+ if (session) query.session(session);
25
+ return query.exec();
26
+ }
27
+
28
+ /** I blocchi dell'agente, come documenti lean. */
29
+ async getBlocks(id_faq_kb, session) {
30
+ const query = Faq.find({ id_faq_kb: id_faq_kb }).sort({ createdAt: 1, _id: 1 }).lean();
31
+ if (session) query.session(session);
32
+ return query.exec();
33
+ }
34
+
35
+ /** Lo stato corrente: agente, blocchi, snapshot e impronta. */
36
+ async currentState(kb, session) {
37
+ const faqs = await this.getBlocks(String(kb._id), session);
38
+ const kbObject = typeof kb.toObject === 'function' ? kb.toObject() : kb;
39
+ const snapshot = snapshotUtil.buildSnapshot(kbObject, faqs);
40
+ return { kb, faqs, snapshot, contentHash: snapshotUtil.hashIntents(snapshot.intents) };
41
+ }
42
+
43
+ /** L'ultima revisione dell'agente, con o senza filtro; null se non ce ne sono. */
44
+ async lastRevision(id_project, id_faq_kb, filter, session) {
45
+ const query = FaqKbRevision.findOne(Object.assign({ id_project: id_project, id_faq_kb: id_faq_kb }, filter || {})).sort({ seq: -1 });
46
+ if (session) query.session(session);
47
+ return query.exec();
48
+ }
49
+
50
+ async getRevision(id_project, revision_id, session) {
51
+ const query = FaqKbRevision.findOne({ id_project: id_project, revision_id: revision_id });
52
+ if (session) query.session(session);
53
+ return query.exec();
54
+ }
55
+
56
+ /**
57
+ * Crea una revisione. `data.snapshot`, se presente, è lo snapshot in chiaro e viene compresso qui.
58
+ * La sequenza è calcolata all'interno; in caso di concorrenza sullo stesso numero si riprova.
59
+ */
60
+ async create(data, session) {
61
+ const doc = {
62
+ revision_id: uuidv4(),
63
+ id_project: data.id_project,
64
+ id_faq_kb: String(data.id_faq_kb),
65
+ kind: data.kind,
66
+ reason: data.reason || null,
67
+ note: data.note ? String(data.note).slice(0, 500) : undefined,
68
+ parent_id: data.parent_id || null,
69
+ ai: data.ai,
70
+ contentHash: data.contentHash || null,
71
+ release_id: data.release_id || null,
72
+ publishedAt: data.publishedAt,
73
+ createdBy: data.createdBy
74
+ };
75
+ if (data.snapshot) {
76
+ doc.snapshot = snapshotUtil.compress(data.snapshot);
77
+ }
78
+
79
+ for (let attempt = 0; attempt < 3; attempt++) {
80
+ const last = await this.lastRevision(doc.id_project, doc.id_faq_kb, null, session);
81
+ doc.seq = last ? last.seq + 1 : 1;
82
+ try {
83
+ const saved = await FaqKbRevision.create([doc], session ? { session: session } : {});
84
+ return saved[0];
85
+ } catch (err) {
86
+ if (err && err.code === 11000 && attempt < 2) {
87
+ winston.debug('(aiGenerations) sequence collision, retrying: ' + doc.seq);
88
+ continue;
89
+ }
90
+ throw err;
91
+ }
92
+ }
93
+ }
94
+
95
+ /** La revisione senza lo snapshot binario, il prompt finale e il Blueprint. */
96
+ light(revision) {
97
+ if (!revision) return null;
98
+ const obj = typeof revision.toObject === 'function' ? revision.toObject() : JSON.parse(JSON.stringify(revision));
99
+ if (obj.snapshot) delete obj.snapshot.data;
100
+ if (obj.ai && obj.ai.request) delete obj.ai.request.finalPrompt;
101
+ if (obj.ai && obj.ai.result) delete obj.ai.result.blueprint;
102
+ return obj;
103
+ }
104
+
105
+ /** La revisione completa, con lo snapshot decompresso in `snapshotData` se richiesto. */
106
+ full(revision, withSnapshot) {
107
+ if (!revision) return null;
108
+ const obj = typeof revision.toObject === 'function' ? revision.toObject() : JSON.parse(JSON.stringify(revision));
109
+ if (withSnapshot && revision.snapshot && revision.snapshot.data) {
110
+ obj.snapshotData = snapshotUtil.decompress(revision.snapshot);
111
+ }
112
+ if (obj.snapshot) delete obj.snapshot.data;
113
+ return obj;
114
+ }
115
+
116
+ async list(id_project, id_faq_kb, page, limit) {
117
+ const filter = { id_project: id_project, id_faq_kb: id_faq_kb };
118
+ const [items, total] = await Promise.all([
119
+ FaqKbRevision.find(filter, LIGHT_PROJECTION).sort({ seq: -1 }).skip(page * limit).limit(limit).lean().exec(),
120
+ FaqKbRevision.countDocuments(filter).exec()
121
+ ]);
122
+ return { items: items, total: total, page: page, limit: limit };
123
+ }
124
+
125
+ async deleteAll(id_project, id_faq_kb) {
126
+ return this.deleteByFilter({ id_project: id_project, id_faq_kb: id_faq_kb });
127
+ }
128
+
129
+ async deleteByFilter(filter) {
130
+ const result = await FaqKbRevision.deleteMany(filter).exec();
131
+ return result.deletedCount || 0;
132
+ }
133
+
134
+ /**
135
+ * Conservazione per agente: si tengono tutte le revisioni AI e le release, le ultime
136
+ * MAX_CHECKPOINTS `checkpoint`, le `auto` più recenti di AUTO_TTL_DAYS giorni, e in tutto
137
+ * al più MAX_PER_AGENT record (si tolgono prima le più vecchie che non sono AI né release).
138
+ * Non tocca mai l'ultima revisione, di qualunque tipo.
139
+ */
140
+ async prune(id_project, id_faq_kb) {
141
+ const filter = { id_project: id_project, id_faq_kb: id_faq_kb };
142
+ const all = await FaqKbRevision.find(filter, { revision_id: 1, kind: 1, seq: 1, createdAt: 1 }).sort({ seq: -1 }).lean().exec();
143
+ if (!all.length) return 0;
144
+ const latest = all[0].revision_id;
145
+ const toDelete = new Set();
146
+
147
+ const checkpoints = all.filter(r => r.kind === 'checkpoint');
148
+ checkpoints.slice(MAX_CHECKPOINTS).forEach(r => toDelete.add(r.revision_id));
149
+
150
+ const autoLimit = Date.now() - AUTO_TTL_DAYS * 24 * 60 * 60 * 1000;
151
+ all.filter(r => r.kind === 'auto' && new Date(r.createdAt).getTime() < autoLimit).forEach(r => toDelete.add(r.revision_id));
152
+
153
+ const remaining = all.filter(r => !toDelete.has(r.revision_id));
154
+ if (remaining.length > MAX_PER_AGENT) {
155
+ const excess = remaining.length - MAX_PER_AGENT;
156
+ const removable = remaining.filter(r => ['checkpoint', 'auto', 'restore'].indexOf(r.kind) !== -1).reverse();
157
+ removable.slice(0, excess).forEach(r => toDelete.add(r.revision_id));
158
+ }
159
+ toDelete.delete(latest);
160
+ if (!toDelete.size) return 0;
161
+ const result = await FaqKbRevision.deleteMany({ id_project: id_project, revision_id: { $in: Array.from(toDelete) } }).exec();
162
+ winston.debug('(aiGenerations) pruned ' + result.deletedCount + ' revisions of ' + id_faq_kb);
163
+ return result.deletedCount || 0;
164
+ }
165
+ }
166
+
167
+ module.exports = new RevisionService();
168
+ module.exports.RevisionService = RevisionService;
169
+ module.exports.LIGHT_PROJECTION = LIGHT_PROJECTION;