@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.
package/CHANGELOG.md CHANGED
@@ -5,8 +5,24 @@
5
5
  🚀 IN PRODUCTION 🚀
6
6
  (https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
7
7
 
8
+
9
+ # 3.0.0
10
+ - Introduced the `aiGenerations` module for agent history and restoration of versions created or modified via AI, with retention options and cache invalidation per agent.
11
+ - Atomic agent creation and editing: blocks are validated before saving, with rollback on errors and all changes (including AI edits and restores from previous versions or published releases) are revisioned.
12
+ - Management of draft versions: manual checkpoints, automatic backups, links to published releases, comparison of changes, and dry-run to check for missing objects during restore.
13
+ - Added optional listeners to keep history clean (e.g., delete history when the agent is deleted) and to create automatic backups after manual changes.
14
+ - Improved V3 agent management: version (`ds_version`) and connectors (`connectors`) are now direct agent fields (no longer in `attributes`), with new GET/PUT endpoints for connector lists, automatic promotion of old properties, and checks on concurrent edit and list validity.
15
+ - More flexible PATCH and PUT: attribute PATCH no longer rejects new V3 bot fields but "promotes" them, accepts preconditions via both header and query param (also for CORS gateways).
16
+ - Maintained compatibility for pre-V3 agents: no impact on existing models and behaviors.
17
+ - Improved V3 bot management: the `ds_version` and `connectors` fields are now direct properties of the bot, no longer in `attributes`.
18
+ - Added new `GET` and `PUT` APIs on `/faq_kb/:id/connectors` to read or update connectors, with validation, concurrency control via `If-Match`, and support only for V3 agents.
19
+ - During export, import, fork, publish, restore, and subagents, V3 fields are kept correctly or promoted if found in legacy JSON.
20
+ - PATCH on attributes now rejects `dsVersion` and `connectors` written in the old style.
21
+ - No changes for previous/non-V3 agents.
22
+ - Added support for returning the blocks list (`GET /:projectid/faq`) without repeating the bot object in each row via the `populate=false` query param. Default behavior remains unchanged.
23
+
8
24
  # 2.29.0
9
- - Added support Subagents
25
+ - Added support for Subagents
10
26
  - Bumped tybot-connector version to 2.3.0
11
27
 
12
28
  # 2.28.2
package/app.js CHANGED
@@ -280,7 +280,7 @@ if (process.env.ENABLE_ALTERNATIVE_CORS_MIDDLEWARE === "true") {
280
280
  app.use(function (req, res, next) {
281
281
  res.header("Access-Control-Allow-Origin", "*"); //qui dice cequens attento
282
282
  // var request_cors_header = req.headers[""]
283
- res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, x-xsrf-token");
283
+ res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, x-xsrf-token, If-Match");
284
284
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
285
285
  next();
286
286
  });
@@ -14,5 +14,12 @@
14
14
  "native": true,
15
15
  "transport": "streamable_http",
16
16
  "description": "Tiledesk Data Table is a MCP server that allows you to manage data tables in the Tiledesk platform."
17
+ },
18
+ {
19
+ "id": "tiledesk-docsparse",
20
+ "name": "Tiledesk Docsparse",
21
+ "url": "https://stage.eks.tiledesk.com/mcp-tools/docsparse/mcp",
22
+ "transport": "streamable_http",
23
+ "description": "Tiledesk Docsparse is a MCP server that extracts the text of documents (PDF, Word, Excel, PowerPoint, CSV, email) from a URL, with OCR for scanned pages."
17
24
  }
18
25
  ]
@@ -5,6 +5,7 @@ const AppError = require("../utils/AppError");
5
5
  const intentsController = require("./intents.controller");
6
6
  const botEvent = require("../event/botEvent");
7
7
  const winston = require("../config/winston");
8
+ const v3FlowService = require("../services/v3FlowService");
8
9
 
9
10
  const IMPORT_OVERRIDE_FIELDS = [
10
11
  "webhook_enabled",
@@ -51,10 +52,23 @@ class ChatbotController {
51
52
  subtype: subagent.subtype,
52
53
  description: subagent.description,
53
54
  attributes: subagent.attributes,
55
+ ...this._v3Fields(subagent),
54
56
  intents: (subagent.intents || []).map((intent) => this._sanitizeIntent(intent))
55
57
  };
56
58
  }
57
59
 
60
+ /** `ds_version` and `connectors` of a V3 agent, for the export; nothing for any other agent. */
61
+ _v3Fields(chatbot) {
62
+ const fields = {};
63
+ if (chatbot && chatbot.ds_version !== undefined && chatbot.ds_version !== null) {
64
+ fields.ds_version = chatbot.ds_version;
65
+ }
66
+ if (chatbot && chatbot.connectors !== undefined && chatbot.connectors !== null) {
67
+ fields.connectors = chatbot.connectors;
68
+ }
69
+ return fields;
70
+ }
71
+
58
72
  export(chatbot, intents, intentsOnly, subagents) {
59
73
  const sanitizedIntents = (intents || []).map((intent) => this._sanitizeIntent(intent));
60
74
 
@@ -72,6 +86,7 @@ class ChatbotController {
72
86
  subtype: chatbot.subtype,
73
87
  description: chatbot.description,
74
88
  attributes: chatbot.attributes,
89
+ ...this._v3Fields(chatbot),
75
90
  parent_id: chatbot.parent_id,
76
91
  intents: sanitizedIntents,
77
92
  subagents: (subagents || []).map((subagent) => this._sanitizeSubagent(subagent))
@@ -211,6 +226,8 @@ class ChatbotController {
211
226
  webhook_url: subagentJson.webhook_url,
212
227
  webhook_enabled: subagentJson.webhook_enabled,
213
228
  attributes: subagentJson.attributes,
229
+ ds_version: subagentJson.ds_version,
230
+ connectors: subagentJson.connectors,
214
231
  parent_id: parent_id.toString(),
215
232
  id_project: id_project.toString(),
216
233
  template: "empty"
@@ -227,7 +244,8 @@ class ChatbotController {
227
244
  if (select) {
228
245
  query = query.select(select);
229
246
  } else {
230
- query = query.sort({ createdAt: -1 });
247
+ // Whole sub agents (export, restore): a V3 sub agent needs its connectors list too.
248
+ query = query.select('+connectors').sort({ createdAt: -1 });
231
249
  }
232
250
  return await query.lean().exec();
233
251
  } catch (err) {
@@ -311,7 +329,7 @@ class ChatbotController {
311
329
  async getChatbot(chatbot_id, id_project) {
312
330
  let chatbot;
313
331
  try {
314
- chatbot = await Chatbot.findOne({ _id: chatbot_id, id_project: id_project });
332
+ chatbot = await Chatbot.findOne({ _id: chatbot_id, id_project: id_project }).select('+connectors');
315
333
  } catch (err) {
316
334
  winston.error("(ChatbotController) getChatbot error: ", err);
317
335
  throw new AppError(500, `Error finding chatbot with id ${chatbot_id}`);
@@ -325,7 +343,7 @@ class ChatbotController {
325
343
  async getChatbotById(chatbot_id) {
326
344
  let chatbot;
327
345
  try {
328
- chatbot = await Chatbot.findById(chatbot_id);
346
+ chatbot = await Chatbot.findById(chatbot_id).select('+connectors');
329
347
  } catch (err) {
330
348
  winston.error("(ChatbotController) getChatbotById error: ", err);
331
349
  throw new AppError(500, `Error finding chatbot with id ${chatbot_id}`);
@@ -382,6 +400,7 @@ class ChatbotController {
382
400
  this._rewriteRulesParticipants(payload.attributes, subagent._id);
383
401
  subagent.attributes = payload.attributes;
384
402
  }
403
+ this._applyV3Fields(subagent, payload);
385
404
 
386
405
  try {
387
406
  subagent = await Chatbot.findByIdAndUpdate(subagent._id, subagent, { new: true });
@@ -453,10 +472,50 @@ class ChatbotController {
453
472
  });
454
473
  }
455
474
 
475
+ /** `ds_version` and `connectors` from an import payload onto a bot; nothing when the payload has none. */
476
+ _applyV3Fields(chatbot, payload) {
477
+ if (!payload) {
478
+ return;
479
+ }
480
+ if (payload.ds_version !== undefined && payload.ds_version !== null) {
481
+ chatbot.ds_version = payload.ds_version;
482
+ }
483
+ if (payload.connectors !== undefined && payload.connectors !== null) {
484
+ chatbot.connectors = payload.connectors;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * A JSON exported before the two fields existed is promoted; a malformed list is refused before
490
+ * anything is written; a V3 agent whose JSON has no list (blocks still written the V2 way) is
491
+ * converted here, so it is stored in V3 form like any other.
492
+ */
493
+ _prepareImportedV3(json) {
494
+ v3FlowService.promoteLegacyJson(json);
495
+ const targets = [json, ...(Array.isArray(json.subagents) ? json.subagents : [])];
496
+ for (const target of targets) {
497
+ if (!target || !v3FlowService.isV3(target)) {
498
+ continue;
499
+ }
500
+ if (target.connectors === undefined || target.connectors === null) {
501
+ if (Array.isArray(target.intents)) {
502
+ target.connectors = v3FlowService.toV3Flow(target.intents).connectors;
503
+ winston.verbose("(ChatbotController) V3 import without a connectors list: built from the blocks, " + target.connectors.items.length + " records");
504
+ }
505
+ continue;
506
+ }
507
+ const errors = v3FlowService.validateConnectors(target.connectors);
508
+ if (errors.length > 0) {
509
+ throw new AppError(400, "Invalid connectors list: " + errors.join("; "));
510
+ }
511
+ }
512
+ }
513
+
456
514
  async import(json, id_project, chatbot_id, create, replace, overwrite, user_id, skipSubagents) {
457
515
  let chatbot;
458
516
  const sourceParentId = this._sourceId(json);
459
517
  this._stripImportedIdentity(json);
518
+ this._prepareImportedV3(json);
460
519
 
461
520
  if (create) {
462
521
  json.template = "empty";
@@ -488,6 +547,7 @@ class ChatbotController {
488
547
  this._rewriteRulesParticipants(json.attributes, chatbot._id);
489
548
  chatbot.attributes = json.attributes;
490
549
  }
550
+ this._applyV3Fields(chatbot, json);
491
551
 
492
552
  try {
493
553
  chatbot = await Chatbot.findByIdAndUpdate(chatbot._id, chatbot, { new: true });
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
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": "2.29.0",
4
+ "version": "3.0.0",
5
5
  "scripts": {
6
6
  "start": "node ./bin/www",
7
7
  "pretest": "mongodb-runner start",
@@ -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)) };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Revisione di un agente (chatbot): la storia delle versioni della bozza.
3
+ *
4
+ * Collection `faq_kb_revisions`, fuori dal percorso dei messaggi. Ogni record porta,
5
+ * tranne che per le `release`, uno snapshot compresso dell'agente (blocchi e impostazioni,
6
+ * senza `attributes.globals`) e l'impronta dei blocchi. Le revisioni `ai_*` portano anche
7
+ * il prompt, il riassunto dell'intervista e il Blueprint della generazione.
8
+ */
9
+ const mongoose = require('mongoose');
10
+ const Schema = mongoose.Schema;
11
+
12
+ const KINDS = ['ai_create', 'ai_edit', 'checkpoint', 'auto', 'restore', 'release'];
13
+ const REASONS = ['before_ai_edit', 'before_restore', 'manual_changes', 'in_place', 'as_copy'];
14
+
15
+ const SnapshotSchema = new Schema({
16
+ encoding: { type: String, default: 'gzip+json' },
17
+ data: { type: Buffer },
18
+ bytes: { type: Number },
19
+ intents: { type: Number }
20
+ }, { _id: false });
21
+
22
+ const FaqKbRevisionSchema = new Schema({
23
+ revision_id: { type: String, required: true },
24
+ id_project: { type: String, required: true, index: true },
25
+ id_faq_kb: { type: String, required: true, index: true },
26
+ seq: { type: Number, required: true, min: 1 },
27
+ kind: { type: String, enum: KINDS, required: true, index: true },
28
+ reason: { type: String, enum: REASONS.concat([null]), default: null },
29
+ note: { type: String, maxlength: 500 },
30
+ parent_id: { type: String, default: null },
31
+ ai: { type: Object },
32
+ snapshot: { type: SnapshotSchema },
33
+ contentHash: { type: String, default: null },
34
+ release_id: { type: String, default: null },
35
+ publishedAt: { type: Date },
36
+ createdBy: { type: String, required: true }
37
+ }, {
38
+ timestamps: true,
39
+ collection: 'faq_kb_revisions'
40
+ });
41
+
42
+ FaqKbRevisionSchema.index({ id_project: 1, id_faq_kb: 1, seq: 1 }, { unique: true });
43
+ FaqKbRevisionSchema.index({ id_project: 1, revision_id: 1 }, { unique: true });
44
+ FaqKbRevisionSchema.index({ id_project: 1, id_faq_kb: 1, createdAt: -1 });
45
+
46
+ const FaqKbRevision = mongoose.model('faq_kb_revision', FaqKbRevisionSchema);
47
+
48
+ FaqKbRevision.KINDS = KINDS;
49
+ FaqKbRevision.REASONS = REASONS;
50
+
51
+ module.exports = FaqKbRevision;