s3db.js 13.0.0 → 13.2.1
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/README.md +9 -9
- package/dist/s3db.cjs.js +3637 -191
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +3637 -191
- package/dist/s3db.es.js.map +1 -1
- package/package.json +2 -1
- package/src/clients/memory-client.class.js +16 -16
- package/src/clients/s3-client.class.js +17 -17
- package/src/concerns/error-classifier.js +204 -0
- package/src/database.class.js +9 -9
- package/src/plugins/api/index.js +1 -7
- package/src/plugins/api/routes/resource-routes.js +3 -3
- package/src/plugins/api/server.js +29 -9
- package/src/plugins/audit.plugin.js +2 -4
- package/src/plugins/backup.plugin.js +10 -12
- package/src/plugins/cache.plugin.js +4 -6
- package/src/plugins/concerns/plugin-dependencies.js +12 -0
- package/src/plugins/costs.plugin.js +0 -2
- package/src/plugins/eventual-consistency/index.js +1 -3
- package/src/plugins/fulltext.plugin.js +2 -4
- package/src/plugins/geo.plugin.js +3 -5
- package/src/plugins/importer/index.js +0 -2
- package/src/plugins/index.js +0 -1
- package/src/plugins/metrics.plugin.js +2 -4
- package/src/plugins/ml.plugin.js +1004 -42
- package/src/plugins/plugin.class.js +1 -3
- package/src/plugins/queue-consumer.plugin.js +1 -3
- package/src/plugins/relation.plugin.js +2 -4
- package/src/plugins/replicator.plugin.js +18 -20
- package/src/plugins/s3-queue.plugin.js +6 -8
- package/src/plugins/scheduler.plugin.js +9 -11
- package/src/plugins/state-machine.errors.js +9 -1
- package/src/plugins/state-machine.plugin.js +605 -20
- package/src/plugins/tfstate/index.js +0 -2
- package/src/plugins/ttl.plugin.js +40 -25
- package/src/plugins/vector.plugin.js +10 -12
- package/src/resource.class.js +58 -40
package/src/plugins/ml.plugin.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Supports regression, classification, time series, and custom neural networks
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import Plugin from './plugin.class.js';
|
|
8
|
+
import { Plugin } from './plugin.class.js';
|
|
9
9
|
import { requirePluginDependency } from './concerns/plugin-dependencies.js';
|
|
10
10
|
import tryFn from '../concerns/try-fn.js';
|
|
11
11
|
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
* @property {Object} models - Model configurations
|
|
30
30
|
* @property {boolean} [verbose=false] - Enable verbose logging
|
|
31
31
|
* @property {number} [minTrainingSamples=10] - Minimum samples required for training
|
|
32
|
+
* @property {boolean} [saveModel=true] - Save trained models to S3
|
|
33
|
+
* @property {boolean} [saveTrainingData=false] - Save intermediate training data to S3
|
|
32
34
|
*
|
|
33
35
|
* @example
|
|
34
36
|
* new MLPlugin({
|
|
@@ -38,9 +40,12 @@ import {
|
|
|
38
40
|
* resource: 'products',
|
|
39
41
|
* features: ['cost', 'margin', 'demand'],
|
|
40
42
|
* target: 'price',
|
|
43
|
+
* partition: { name: 'byCategory', values: { category: 'electronics' } }, // Optional
|
|
41
44
|
* autoTrain: true,
|
|
42
45
|
* trainInterval: 3600000, // 1 hour
|
|
43
46
|
* trainAfterInserts: 100,
|
|
47
|
+
* saveModel: true, // Save to S3 after training
|
|
48
|
+
* saveTrainingData: true, // Save prepared dataset
|
|
44
49
|
* modelConfig: {
|
|
45
50
|
* epochs: 50,
|
|
46
51
|
* batchSize: 32,
|
|
@@ -48,7 +53,9 @@ import {
|
|
|
48
53
|
* }
|
|
49
54
|
* }
|
|
50
55
|
* },
|
|
51
|
-
* verbose: true
|
|
56
|
+
* verbose: true,
|
|
57
|
+
* saveModel: true,
|
|
58
|
+
* saveTrainingData: false
|
|
52
59
|
* })
|
|
53
60
|
*/
|
|
54
61
|
export class MLPlugin extends Plugin {
|
|
@@ -58,18 +65,24 @@ export class MLPlugin extends Plugin {
|
|
|
58
65
|
this.config = {
|
|
59
66
|
models: options.models || {},
|
|
60
67
|
verbose: options.verbose || false,
|
|
61
|
-
minTrainingSamples: options.minTrainingSamples || 10
|
|
68
|
+
minTrainingSamples: options.minTrainingSamples || 10,
|
|
69
|
+
saveModel: options.saveModel !== false, // Default true
|
|
70
|
+
saveTrainingData: options.saveTrainingData || false,
|
|
71
|
+
enableVersioning: options.enableVersioning !== false // Default true
|
|
62
72
|
};
|
|
63
73
|
|
|
64
74
|
// Validate TensorFlow.js dependency
|
|
65
|
-
requirePluginDependency('
|
|
66
|
-
installCommand: 'pnpm add @tensorflow/tfjs-node',
|
|
67
|
-
reason: 'Required for machine learning model training and inference'
|
|
68
|
-
});
|
|
75
|
+
requirePluginDependency('ml-plugin');
|
|
69
76
|
|
|
70
77
|
// Model instances
|
|
71
78
|
this.models = {};
|
|
72
79
|
|
|
80
|
+
// Model versioning
|
|
81
|
+
this.modelVersions = new Map(); // Track versions per model: { currentVersion, latestVersion }
|
|
82
|
+
|
|
83
|
+
// Model cache for resource.predict()
|
|
84
|
+
this.modelCache = new Map(); // Cache: resourceName_attribute -> modelName
|
|
85
|
+
|
|
73
86
|
// Training state
|
|
74
87
|
this.training = new Map(); // Track ongoing training
|
|
75
88
|
this.insertCounters = new Map(); // Track inserts per resource
|
|
@@ -104,6 +117,12 @@ export class MLPlugin extends Plugin {
|
|
|
104
117
|
await this._initializeModel(modelName, modelConfig);
|
|
105
118
|
}
|
|
106
119
|
|
|
120
|
+
// Build model cache (resource -> attribute -> modelName mapping)
|
|
121
|
+
this._buildModelCache();
|
|
122
|
+
|
|
123
|
+
// Inject ML methods into Resource prototype
|
|
124
|
+
this._injectResourceMethods();
|
|
125
|
+
|
|
107
126
|
// Setup auto-training hooks
|
|
108
127
|
for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
|
|
109
128
|
if (modelConfig.autoTrain) {
|
|
@@ -117,7 +136,7 @@ export class MLPlugin extends Plugin {
|
|
|
117
136
|
console.log(`[MLPlugin] Installed with ${Object.keys(this.models).length} models`);
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
this.emit('installed', {
|
|
139
|
+
this.emit('db:plugin:installed', {
|
|
121
140
|
plugin: 'MLPlugin',
|
|
122
141
|
models: Object.keys(this.models)
|
|
123
142
|
});
|
|
@@ -127,6 +146,13 @@ export class MLPlugin extends Plugin {
|
|
|
127
146
|
* Start the plugin
|
|
128
147
|
*/
|
|
129
148
|
async onStart() {
|
|
149
|
+
// Initialize versioning for each model
|
|
150
|
+
if (this.config.enableVersioning) {
|
|
151
|
+
for (const modelName of Object.keys(this.models)) {
|
|
152
|
+
await this._initializeVersioning(modelName);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
130
156
|
// Try to load previously trained models
|
|
131
157
|
for (const modelName of Object.keys(this.models)) {
|
|
132
158
|
await this._loadModel(modelName);
|
|
@@ -166,15 +192,172 @@ export class MLPlugin extends Plugin {
|
|
|
166
192
|
await this.onStop();
|
|
167
193
|
|
|
168
194
|
if (options.purgeData) {
|
|
169
|
-
// Delete all saved models from plugin storage
|
|
195
|
+
// Delete all saved models and training data from plugin storage
|
|
170
196
|
for (const modelName of Object.keys(this.models)) {
|
|
171
197
|
await this._deleteModel(modelName);
|
|
198
|
+
await this._deleteTrainingData(modelName);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (this.config.verbose) {
|
|
202
|
+
console.log('[MLPlugin] Purged all model data and training data');
|
|
172
203
|
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Build model cache for fast lookup
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_buildModelCache() {
|
|
212
|
+
for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
|
|
213
|
+
const cacheKey = `${modelConfig.resource}_${modelConfig.target}`;
|
|
214
|
+
this.modelCache.set(cacheKey, modelName);
|
|
173
215
|
|
|
174
216
|
if (this.config.verbose) {
|
|
175
|
-
console.log(
|
|
217
|
+
console.log(`[MLPlugin] Cached model "${modelName}" for ${modelConfig.resource}.predict(..., '${modelConfig.target}')`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Inject ML methods into Resource instances
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
_injectResourceMethods() {
|
|
227
|
+
const plugin = this;
|
|
228
|
+
|
|
229
|
+
// Store reference to plugin in database for resource access
|
|
230
|
+
if (!this.database._mlPlugin) {
|
|
231
|
+
this.database._mlPlugin = this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Add predict() method to Resource prototype
|
|
235
|
+
if (!this.database.Resource.prototype.predict) {
|
|
236
|
+
this.database.Resource.prototype.predict = async function(input, targetAttribute) {
|
|
237
|
+
const mlPlugin = this.database._mlPlugin;
|
|
238
|
+
if (!mlPlugin) {
|
|
239
|
+
throw new Error('MLPlugin not installed');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return await mlPlugin._resourcePredict(this.name, input, targetAttribute);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Add trainModel() method to Resource prototype
|
|
247
|
+
if (!this.database.Resource.prototype.trainModel) {
|
|
248
|
+
this.database.Resource.prototype.trainModel = async function(targetAttribute, options = {}) {
|
|
249
|
+
const mlPlugin = this.database._mlPlugin;
|
|
250
|
+
if (!mlPlugin) {
|
|
251
|
+
throw new Error('MLPlugin not installed');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return await mlPlugin._resourceTrainModel(this.name, targetAttribute, options);
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Add listModels() method to Resource prototype
|
|
259
|
+
if (!this.database.Resource.prototype.listModels) {
|
|
260
|
+
this.database.Resource.prototype.listModels = function() {
|
|
261
|
+
const mlPlugin = this.database._mlPlugin;
|
|
262
|
+
if (!mlPlugin) {
|
|
263
|
+
throw new Error('MLPlugin not installed');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return mlPlugin._resourceListModels(this.name);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (this.config.verbose) {
|
|
271
|
+
console.log('[MLPlugin] Injected ML methods into Resource prototype');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Find model for a resource and target attribute
|
|
277
|
+
* @private
|
|
278
|
+
*/
|
|
279
|
+
_findModelForResource(resourceName, targetAttribute) {
|
|
280
|
+
const cacheKey = `${resourceName}_${targetAttribute}`;
|
|
281
|
+
|
|
282
|
+
// Try cache first
|
|
283
|
+
if (this.modelCache.has(cacheKey)) {
|
|
284
|
+
return this.modelCache.get(cacheKey);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Search through all models
|
|
288
|
+
for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
|
|
289
|
+
if (modelConfig.resource === resourceName && modelConfig.target === targetAttribute) {
|
|
290
|
+
// Cache for next time
|
|
291
|
+
this.modelCache.set(cacheKey, modelName);
|
|
292
|
+
return modelName;
|
|
176
293
|
}
|
|
177
294
|
}
|
|
295
|
+
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resource predict implementation
|
|
301
|
+
* @private
|
|
302
|
+
*/
|
|
303
|
+
async _resourcePredict(resourceName, input, targetAttribute) {
|
|
304
|
+
const modelName = this._findModelForResource(resourceName, targetAttribute);
|
|
305
|
+
|
|
306
|
+
if (!modelName) {
|
|
307
|
+
throw new ModelNotFoundError(
|
|
308
|
+
`No model found for resource "${resourceName}" with target "${targetAttribute}"`,
|
|
309
|
+
{ resourceName, targetAttribute, availableModels: Object.keys(this.models) }
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (this.config.verbose) {
|
|
314
|
+
console.log(`[MLPlugin] Resource prediction: ${resourceName}.predict(..., '${targetAttribute}') -> model "${modelName}"`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return await this.predict(modelName, input);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resource trainModel implementation
|
|
322
|
+
* @private
|
|
323
|
+
*/
|
|
324
|
+
async _resourceTrainModel(resourceName, targetAttribute, options = {}) {
|
|
325
|
+
const modelName = this._findModelForResource(resourceName, targetAttribute);
|
|
326
|
+
|
|
327
|
+
if (!modelName) {
|
|
328
|
+
throw new ModelNotFoundError(
|
|
329
|
+
`No model found for resource "${resourceName}" with target "${targetAttribute}"`,
|
|
330
|
+
{ resourceName, targetAttribute, availableModels: Object.keys(this.models) }
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (this.config.verbose) {
|
|
335
|
+
console.log(`[MLPlugin] Resource training: ${resourceName}.trainModel('${targetAttribute}') -> model "${modelName}"`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return await this.train(modelName, options);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* List models for a resource
|
|
343
|
+
* @private
|
|
344
|
+
*/
|
|
345
|
+
_resourceListModels(resourceName) {
|
|
346
|
+
const models = [];
|
|
347
|
+
|
|
348
|
+
for (const [modelName, modelConfig] of Object.entries(this.config.models)) {
|
|
349
|
+
if (modelConfig.resource === resourceName) {
|
|
350
|
+
models.push({
|
|
351
|
+
name: modelName,
|
|
352
|
+
type: modelConfig.type,
|
|
353
|
+
target: modelConfig.target,
|
|
354
|
+
features: modelConfig.features,
|
|
355
|
+
isTrained: this.models[modelName]?.isTrained || false
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return models;
|
|
178
361
|
}
|
|
179
362
|
|
|
180
363
|
/**
|
|
@@ -365,18 +548,67 @@ export class MLPlugin extends Plugin {
|
|
|
365
548
|
);
|
|
366
549
|
}
|
|
367
550
|
|
|
368
|
-
// Fetch training data
|
|
551
|
+
// Fetch training data (with optional partition filtering)
|
|
369
552
|
if (this.config.verbose) {
|
|
370
553
|
console.log(`[MLPlugin] Fetching training data for "${modelName}"...`);
|
|
371
554
|
}
|
|
372
555
|
|
|
373
|
-
|
|
556
|
+
let data;
|
|
557
|
+
const partition = modelConfig.partition;
|
|
374
558
|
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
559
|
+
if (partition && partition.name) {
|
|
560
|
+
// Use partition filtering
|
|
561
|
+
if (this.config.verbose) {
|
|
562
|
+
console.log(`[MLPlugin] Using partition "${partition.name}" with values:`, partition.values);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const [ok, err, partitionData] = await tryFn(() =>
|
|
566
|
+
resource.listPartition(partition.name, partition.values)
|
|
379
567
|
);
|
|
568
|
+
|
|
569
|
+
if (!ok) {
|
|
570
|
+
throw new TrainingError(
|
|
571
|
+
`Failed to fetch training data from partition: ${err.message}`,
|
|
572
|
+
{ modelName, resource: modelConfig.resource, partition: partition.name, originalError: err.message }
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
data = partitionData;
|
|
577
|
+
} else {
|
|
578
|
+
// Fetch all data
|
|
579
|
+
const [ok, err, allData] = await tryFn(() => resource.list());
|
|
580
|
+
|
|
581
|
+
if (!ok) {
|
|
582
|
+
throw new TrainingError(
|
|
583
|
+
`Failed to fetch training data: ${err.message}`,
|
|
584
|
+
{ modelName, resource: modelConfig.resource, originalError: err.message }
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
data = allData;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Apply custom filter function if provided
|
|
592
|
+
if (modelConfig.filter && typeof modelConfig.filter === 'function') {
|
|
593
|
+
if (this.config.verbose) {
|
|
594
|
+
console.log(`[MLPlugin] Applying custom filter function...`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const originalLength = data.length;
|
|
598
|
+
data = data.filter(modelConfig.filter);
|
|
599
|
+
|
|
600
|
+
if (this.config.verbose) {
|
|
601
|
+
console.log(`[MLPlugin] Filter reduced dataset from ${originalLength} to ${data.length} samples`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Apply custom map function if provided
|
|
606
|
+
if (modelConfig.map && typeof modelConfig.map === 'function') {
|
|
607
|
+
if (this.config.verbose) {
|
|
608
|
+
console.log(`[MLPlugin] Applying custom map function...`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
data = data.map(modelConfig.map);
|
|
380
612
|
}
|
|
381
613
|
|
|
382
614
|
if (!data || data.length < this.config.minTrainingSamples) {
|
|
@@ -390,11 +622,26 @@ export class MLPlugin extends Plugin {
|
|
|
390
622
|
console.log(`[MLPlugin] Training "${modelName}" with ${data.length} samples...`);
|
|
391
623
|
}
|
|
392
624
|
|
|
625
|
+
// Save intermediate training data if enabled
|
|
626
|
+
const shouldSaveTrainingData = modelConfig.saveTrainingData !== undefined
|
|
627
|
+
? modelConfig.saveTrainingData
|
|
628
|
+
: this.config.saveTrainingData;
|
|
629
|
+
|
|
630
|
+
if (shouldSaveTrainingData) {
|
|
631
|
+
await this._saveTrainingData(modelName, data);
|
|
632
|
+
}
|
|
633
|
+
|
|
393
634
|
// Train model
|
|
394
635
|
const result = await model.train(data);
|
|
395
636
|
|
|
396
|
-
// Save model to plugin storage
|
|
397
|
-
|
|
637
|
+
// Save model to plugin storage if enabled
|
|
638
|
+
const shouldSaveModel = modelConfig.saveModel !== undefined
|
|
639
|
+
? modelConfig.saveModel
|
|
640
|
+
: this.config.saveModel;
|
|
641
|
+
|
|
642
|
+
if (shouldSaveModel) {
|
|
643
|
+
await this._saveModel(modelName);
|
|
644
|
+
}
|
|
398
645
|
|
|
399
646
|
this.stats.totalTrainings++;
|
|
400
647
|
|
|
@@ -402,7 +649,7 @@ export class MLPlugin extends Plugin {
|
|
|
402
649
|
console.log(`[MLPlugin] Training completed for "${modelName}":`, result);
|
|
403
650
|
}
|
|
404
651
|
|
|
405
|
-
this.emit('
|
|
652
|
+
this.emit('plg:ml:model-trained', {
|
|
406
653
|
modelName,
|
|
407
654
|
type: modelConfig.type,
|
|
408
655
|
result
|
|
@@ -444,7 +691,7 @@ export class MLPlugin extends Plugin {
|
|
|
444
691
|
const result = await model.predict(input);
|
|
445
692
|
this.stats.totalPredictions++;
|
|
446
693
|
|
|
447
|
-
this.emit('prediction', {
|
|
694
|
+
this.emit('plg:ml:prediction', {
|
|
448
695
|
modelName,
|
|
449
696
|
input,
|
|
450
697
|
result
|
|
@@ -573,6 +820,91 @@ export class MLPlugin extends Plugin {
|
|
|
573
820
|
}
|
|
574
821
|
}
|
|
575
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Initialize versioning for a model
|
|
825
|
+
* @private
|
|
826
|
+
*/
|
|
827
|
+
async _initializeVersioning(modelName) {
|
|
828
|
+
try {
|
|
829
|
+
const storage = this.getStorage();
|
|
830
|
+
const modelConfig = this.config.models[modelName];
|
|
831
|
+
const resourceName = modelConfig.resource;
|
|
832
|
+
const [ok, err, versionInfo] = await tryFn(() =>
|
|
833
|
+
storage.get(storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'))
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
if (ok && versionInfo) {
|
|
837
|
+
// Load existing version info
|
|
838
|
+
this.modelVersions.set(modelName, {
|
|
839
|
+
currentVersion: versionInfo.currentVersion || 1,
|
|
840
|
+
latestVersion: versionInfo.latestVersion || 1
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
if (this.config.verbose) {
|
|
844
|
+
console.log(`[MLPlugin] Loaded version info for "${modelName}": v${versionInfo.currentVersion}`);
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
// Initialize new versioning
|
|
848
|
+
this.modelVersions.set(modelName, {
|
|
849
|
+
currentVersion: 1,
|
|
850
|
+
latestVersion: 0 // No versions yet
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (this.config.verbose) {
|
|
854
|
+
console.log(`[MLPlugin] Initialized versioning for "${modelName}"`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
} catch (error) {
|
|
858
|
+
console.error(`[MLPlugin] Failed to initialize versioning for "${modelName}":`, error.message);
|
|
859
|
+
// Fallback to v1
|
|
860
|
+
this.modelVersions.set(modelName, { currentVersion: 1, latestVersion: 0 });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Get next version number for a model
|
|
866
|
+
* @private
|
|
867
|
+
*/
|
|
868
|
+
_getNextVersion(modelName) {
|
|
869
|
+
const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
|
|
870
|
+
return versionInfo.latestVersion + 1;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Update version info in storage
|
|
875
|
+
* @private
|
|
876
|
+
*/
|
|
877
|
+
async _updateVersionInfo(modelName, version) {
|
|
878
|
+
try {
|
|
879
|
+
const storage = this.getStorage();
|
|
880
|
+
const modelConfig = this.config.models[modelName];
|
|
881
|
+
const resourceName = modelConfig.resource;
|
|
882
|
+
const versionInfo = this.modelVersions.get(modelName) || { currentVersion: 1, latestVersion: 0 };
|
|
883
|
+
|
|
884
|
+
versionInfo.latestVersion = Math.max(versionInfo.latestVersion, version);
|
|
885
|
+
versionInfo.currentVersion = version; // Set new version as current
|
|
886
|
+
|
|
887
|
+
this.modelVersions.set(modelName, versionInfo);
|
|
888
|
+
|
|
889
|
+
await storage.set(
|
|
890
|
+
storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'),
|
|
891
|
+
{
|
|
892
|
+
modelName,
|
|
893
|
+
currentVersion: versionInfo.currentVersion,
|
|
894
|
+
latestVersion: versionInfo.latestVersion,
|
|
895
|
+
updatedAt: new Date().toISOString()
|
|
896
|
+
},
|
|
897
|
+
{ behavior: 'body-overflow' }
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
if (this.config.verbose) {
|
|
901
|
+
console.log(`[MLPlugin] Updated version info for "${modelName}": current=v${versionInfo.currentVersion}, latest=v${versionInfo.latestVersion}`);
|
|
902
|
+
}
|
|
903
|
+
} catch (error) {
|
|
904
|
+
console.error(`[MLPlugin] Failed to update version info for "${modelName}":`, error.message);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
576
908
|
/**
|
|
577
909
|
* Save model to plugin storage
|
|
578
910
|
* @private
|
|
@@ -580,6 +912,8 @@ export class MLPlugin extends Plugin {
|
|
|
580
912
|
async _saveModel(modelName) {
|
|
581
913
|
try {
|
|
582
914
|
const storage = this.getStorage();
|
|
915
|
+
const modelConfig = this.config.models[modelName];
|
|
916
|
+
const resourceName = modelConfig.resource;
|
|
583
917
|
const exportedModel = await this.models[modelName].export();
|
|
584
918
|
|
|
585
919
|
if (!exportedModel) {
|
|
@@ -589,21 +923,198 @@ export class MLPlugin extends Plugin {
|
|
|
589
923
|
return;
|
|
590
924
|
}
|
|
591
925
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
926
|
+
const enableVersioning = this.config.enableVersioning;
|
|
927
|
+
|
|
928
|
+
if (enableVersioning) {
|
|
929
|
+
// Save with version
|
|
930
|
+
const version = this._getNextVersion(modelName);
|
|
931
|
+
const modelStats = this.models[modelName].getStats();
|
|
932
|
+
|
|
933
|
+
// Save versioned model binary to S3 body
|
|
934
|
+
await storage.set(
|
|
935
|
+
storage.getPluginKey(resourceName, 'models', modelName, `v${version}`),
|
|
936
|
+
{
|
|
937
|
+
modelName,
|
|
938
|
+
version,
|
|
939
|
+
type: 'model',
|
|
940
|
+
modelData: exportedModel, // TensorFlow.js model object (will go to body)
|
|
941
|
+
metrics: {
|
|
942
|
+
loss: modelStats.loss,
|
|
943
|
+
accuracy: modelStats.accuracy,
|
|
944
|
+
samples: modelStats.samples
|
|
945
|
+
},
|
|
946
|
+
savedAt: new Date().toISOString()
|
|
947
|
+
},
|
|
948
|
+
{ behavior: 'body-only' } // Large binary data goes to S3 body
|
|
949
|
+
);
|
|
598
950
|
|
|
599
|
-
|
|
600
|
-
|
|
951
|
+
// Update version info
|
|
952
|
+
await this._updateVersionInfo(modelName, version);
|
|
953
|
+
|
|
954
|
+
// Save active reference (points to current version)
|
|
955
|
+
await storage.set(
|
|
956
|
+
storage.getPluginKey(resourceName, 'metadata', modelName, 'active'),
|
|
957
|
+
{
|
|
958
|
+
modelName,
|
|
959
|
+
version,
|
|
960
|
+
type: 'reference',
|
|
961
|
+
updatedAt: new Date().toISOString()
|
|
962
|
+
},
|
|
963
|
+
{ behavior: 'body-overflow' } // Small metadata
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
if (this.config.verbose) {
|
|
967
|
+
console.log(`[MLPlugin] Saved model "${modelName}" v${version} to S3 (resource=${resourceName}/plugin=ml/models/${modelName}/v${version})`);
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
// Save without versioning (legacy behavior)
|
|
971
|
+
await storage.set(
|
|
972
|
+
storage.getPluginKey(resourceName, 'models', modelName, 'latest'),
|
|
973
|
+
{
|
|
974
|
+
modelName,
|
|
975
|
+
type: 'model',
|
|
976
|
+
modelData: exportedModel,
|
|
977
|
+
savedAt: new Date().toISOString()
|
|
978
|
+
},
|
|
979
|
+
{ behavior: 'body-only' }
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
if (this.config.verbose) {
|
|
983
|
+
console.log(`[MLPlugin] Saved model "${modelName}" to S3 (resource=${resourceName}/plugin=ml/models/${modelName}/latest)`);
|
|
984
|
+
}
|
|
601
985
|
}
|
|
602
986
|
} catch (error) {
|
|
603
987
|
console.error(`[MLPlugin] Failed to save model "${modelName}":`, error.message);
|
|
604
988
|
}
|
|
605
989
|
}
|
|
606
990
|
|
|
991
|
+
/**
|
|
992
|
+
* Save intermediate training data to plugin storage (incremental - only new samples)
|
|
993
|
+
* @private
|
|
994
|
+
*/
|
|
995
|
+
async _saveTrainingData(modelName, rawData) {
|
|
996
|
+
try {
|
|
997
|
+
const storage = this.getStorage();
|
|
998
|
+
const model = this.models[modelName];
|
|
999
|
+
const modelConfig = this.config.models[modelName];
|
|
1000
|
+
const resourceName = modelConfig.resource;
|
|
1001
|
+
const modelStats = model.getStats();
|
|
1002
|
+
const enableVersioning = this.config.enableVersioning;
|
|
1003
|
+
|
|
1004
|
+
// Extract features and target from raw data
|
|
1005
|
+
const processedData = rawData.map(item => {
|
|
1006
|
+
const features = {};
|
|
1007
|
+
modelConfig.features.forEach(feature => {
|
|
1008
|
+
features[feature] = item[feature];
|
|
1009
|
+
});
|
|
1010
|
+
return {
|
|
1011
|
+
id: item.id || `${Date.now()}_${Math.random()}`, // Use record ID or generate
|
|
1012
|
+
features,
|
|
1013
|
+
target: item[modelConfig.target]
|
|
1014
|
+
};
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
if (enableVersioning) {
|
|
1018
|
+
const version = this._getNextVersion(modelName);
|
|
1019
|
+
|
|
1020
|
+
// Load existing history to calculate incremental data
|
|
1021
|
+
const [ok, err, existing] = await tryFn(() =>
|
|
1022
|
+
storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
let history = [];
|
|
1026
|
+
let previousSampleIds = new Set();
|
|
1027
|
+
|
|
1028
|
+
if (ok && existing && existing.history) {
|
|
1029
|
+
history = existing.history;
|
|
1030
|
+
// Collect all IDs from previous versions
|
|
1031
|
+
history.forEach(entry => {
|
|
1032
|
+
if (entry.sampleIds) {
|
|
1033
|
+
entry.sampleIds.forEach(id => previousSampleIds.add(id));
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Detect new samples (not in previous versions)
|
|
1039
|
+
const currentSampleIds = new Set(processedData.map(d => d.id));
|
|
1040
|
+
const newSamples = processedData.filter(d => !previousSampleIds.has(d.id));
|
|
1041
|
+
const newSampleIds = newSamples.map(d => d.id);
|
|
1042
|
+
|
|
1043
|
+
// Save only NEW samples to S3 body (incremental)
|
|
1044
|
+
if (newSamples.length > 0) {
|
|
1045
|
+
await storage.set(
|
|
1046
|
+
storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${version}`),
|
|
1047
|
+
{
|
|
1048
|
+
modelName,
|
|
1049
|
+
version,
|
|
1050
|
+
samples: newSamples, // Only new samples
|
|
1051
|
+
features: modelConfig.features,
|
|
1052
|
+
target: modelConfig.target,
|
|
1053
|
+
savedAt: new Date().toISOString()
|
|
1054
|
+
},
|
|
1055
|
+
{ behavior: 'body-only' } // Dataset goes to S3 body
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Append metadata to history (no full dataset duplication)
|
|
1060
|
+
const historyEntry = {
|
|
1061
|
+
version,
|
|
1062
|
+
totalSamples: processedData.length, // Total cumulative
|
|
1063
|
+
newSamples: newSamples.length, // Only new in this version
|
|
1064
|
+
sampleIds: Array.from(currentSampleIds), // All IDs for this version
|
|
1065
|
+
newSampleIds, // IDs of new samples
|
|
1066
|
+
storageKey: newSamples.length > 0 ? `training/data/${modelName}/v${version}` : null,
|
|
1067
|
+
metrics: {
|
|
1068
|
+
loss: modelStats.loss,
|
|
1069
|
+
accuracy: modelStats.accuracy,
|
|
1070
|
+
r2: modelStats.r2
|
|
1071
|
+
},
|
|
1072
|
+
trainedAt: new Date().toISOString()
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
history.push(historyEntry);
|
|
1076
|
+
|
|
1077
|
+
// Save updated history (metadata only, no full datasets)
|
|
1078
|
+
await storage.set(
|
|
1079
|
+
storage.getPluginKey(resourceName, 'training', 'history', modelName),
|
|
1080
|
+
{
|
|
1081
|
+
modelName,
|
|
1082
|
+
type: 'training_history',
|
|
1083
|
+
totalTrainings: history.length,
|
|
1084
|
+
latestVersion: version,
|
|
1085
|
+
history, // Array of metadata entries (not full data)
|
|
1086
|
+
updatedAt: new Date().toISOString()
|
|
1087
|
+
},
|
|
1088
|
+
{ behavior: 'body-overflow' } // History metadata
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
if (this.config.verbose) {
|
|
1092
|
+
console.log(`[MLPlugin] Saved training data for "${modelName}" v${version}: ${newSamples.length} new samples (total: ${processedData.length}, storage: resource=${resourceName}/plugin=ml/training/data/${modelName}/v${version})`);
|
|
1093
|
+
}
|
|
1094
|
+
} else {
|
|
1095
|
+
// Legacy: Replace training data (non-incremental)
|
|
1096
|
+
await storage.set(
|
|
1097
|
+
storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'),
|
|
1098
|
+
{
|
|
1099
|
+
modelName,
|
|
1100
|
+
type: 'training_data',
|
|
1101
|
+
samples: processedData,
|
|
1102
|
+
features: modelConfig.features,
|
|
1103
|
+
target: modelConfig.target,
|
|
1104
|
+
savedAt: new Date().toISOString()
|
|
1105
|
+
},
|
|
1106
|
+
{ behavior: 'body-only' }
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
if (this.config.verbose) {
|
|
1110
|
+
console.log(`[MLPlugin] Saved training data for "${modelName}" (${processedData.length} samples) to S3 (resource=${resourceName}/plugin=ml/training/data/${modelName}/latest)`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
console.error(`[MLPlugin] Failed to save training data for "${modelName}":`, error.message);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
607
1118
|
/**
|
|
608
1119
|
* Load model from plugin storage
|
|
609
1120
|
* @private
|
|
@@ -611,20 +1122,72 @@ export class MLPlugin extends Plugin {
|
|
|
611
1122
|
async _loadModel(modelName) {
|
|
612
1123
|
try {
|
|
613
1124
|
const storage = this.getStorage();
|
|
614
|
-
const
|
|
1125
|
+
const modelConfig = this.config.models[modelName];
|
|
1126
|
+
const resourceName = modelConfig.resource;
|
|
1127
|
+
const enableVersioning = this.config.enableVersioning;
|
|
1128
|
+
|
|
1129
|
+
if (enableVersioning) {
|
|
1130
|
+
// Load active version reference
|
|
1131
|
+
const [okRef, errRef, activeRef] = await tryFn(() =>
|
|
1132
|
+
storage.get(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'))
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
if (okRef && activeRef && activeRef.version) {
|
|
1136
|
+
// Load the active version
|
|
1137
|
+
const version = activeRef.version;
|
|
1138
|
+
const [ok, err, versionData] = await tryFn(() =>
|
|
1139
|
+
storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`))
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
if (ok && versionData && versionData.modelData) {
|
|
1143
|
+
await this.models[modelName].import(versionData.modelData);
|
|
1144
|
+
|
|
1145
|
+
if (this.config.verbose) {
|
|
1146
|
+
console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (active) from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/v${version})`);
|
|
1147
|
+
}
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// No active reference, try to load latest version
|
|
1153
|
+
const versionInfo = this.modelVersions.get(modelName);
|
|
1154
|
+
if (versionInfo && versionInfo.latestVersion > 0) {
|
|
1155
|
+
const version = versionInfo.latestVersion;
|
|
1156
|
+
const [ok, err, versionData] = await tryFn(() =>
|
|
1157
|
+
storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`))
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
if (ok && versionData && versionData.modelData) {
|
|
1161
|
+
await this.models[modelName].import(versionData.modelData);
|
|
1162
|
+
|
|
1163
|
+
if (this.config.verbose) {
|
|
1164
|
+
console.log(`[MLPlugin] Loaded model "${modelName}" v${version} (latest) from S3`);
|
|
1165
|
+
}
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
615
1169
|
|
|
616
|
-
if (!ok || !record) {
|
|
617
1170
|
if (this.config.verbose) {
|
|
618
|
-
console.log(`[MLPlugin] No saved model found for "${modelName}"`);
|
|
1171
|
+
console.log(`[MLPlugin] No saved model versions found for "${modelName}"`);
|
|
619
1172
|
}
|
|
620
|
-
|
|
621
|
-
|
|
1173
|
+
} else {
|
|
1174
|
+
// Legacy: Load non-versioned model
|
|
1175
|
+
const [ok, err, record] = await tryFn(() =>
|
|
1176
|
+
storage.get(storage.getPluginKey(resourceName, 'models', modelName, 'latest'))
|
|
1177
|
+
);
|
|
622
1178
|
|
|
623
|
-
|
|
624
|
-
|
|
1179
|
+
if (!ok || !record || !record.modelData) {
|
|
1180
|
+
if (this.config.verbose) {
|
|
1181
|
+
console.log(`[MLPlugin] No saved model found for "${modelName}"`);
|
|
1182
|
+
}
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
625
1185
|
|
|
626
|
-
|
|
627
|
-
|
|
1186
|
+
await this.models[modelName].import(record.modelData);
|
|
1187
|
+
|
|
1188
|
+
if (this.config.verbose) {
|
|
1189
|
+
console.log(`[MLPlugin] Loaded model "${modelName}" from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/latest)`);
|
|
1190
|
+
}
|
|
628
1191
|
}
|
|
629
1192
|
} catch (error) {
|
|
630
1193
|
console.error(`[MLPlugin] Failed to load model "${modelName}":`, error.message);
|
|
@@ -632,16 +1195,119 @@ export class MLPlugin extends Plugin {
|
|
|
632
1195
|
}
|
|
633
1196
|
|
|
634
1197
|
/**
|
|
635
|
-
*
|
|
1198
|
+
* Load training data from plugin storage (reconstructs specific version from incremental data)
|
|
1199
|
+
* @param {string} modelName - Model name
|
|
1200
|
+
* @param {number} version - Version number (optional, defaults to latest)
|
|
1201
|
+
* @returns {Object|null} Training data or null if not found
|
|
1202
|
+
*/
|
|
1203
|
+
async getTrainingData(modelName, version = null) {
|
|
1204
|
+
try {
|
|
1205
|
+
const storage = this.getStorage();
|
|
1206
|
+
const modelConfig = this.config.models[modelName];
|
|
1207
|
+
const resourceName = modelConfig.resource;
|
|
1208
|
+
const enableVersioning = this.config.enableVersioning;
|
|
1209
|
+
|
|
1210
|
+
if (!enableVersioning) {
|
|
1211
|
+
// Legacy: Load non-versioned training data
|
|
1212
|
+
const [ok, err, record] = await tryFn(() =>
|
|
1213
|
+
storage.get(storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'))
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
if (!ok || !record) {
|
|
1217
|
+
if (this.config.verbose) {
|
|
1218
|
+
console.log(`[MLPlugin] No saved training data found for "${modelName}"`);
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return {
|
|
1224
|
+
modelName: record.modelName,
|
|
1225
|
+
samples: record.samples,
|
|
1226
|
+
features: record.features,
|
|
1227
|
+
target: record.target,
|
|
1228
|
+
data: record.samples,
|
|
1229
|
+
savedAt: record.savedAt
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Versioned: Reconstruct dataset from incremental versions
|
|
1234
|
+
const [okHistory, errHistory, historyData] = await tryFn(() =>
|
|
1235
|
+
storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
if (!okHistory || !historyData || !historyData.history) {
|
|
1239
|
+
if (this.config.verbose) {
|
|
1240
|
+
console.log(`[MLPlugin] No training history found for "${modelName}"`);
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const targetVersion = version || historyData.latestVersion;
|
|
1246
|
+
const reconstructedSamples = [];
|
|
1247
|
+
|
|
1248
|
+
// Load and combine all versions up to target version
|
|
1249
|
+
for (const entry of historyData.history) {
|
|
1250
|
+
if (entry.version > targetVersion) break;
|
|
1251
|
+
|
|
1252
|
+
if (entry.storageKey && entry.newSamples > 0) {
|
|
1253
|
+
const [ok, err, versionData] = await tryFn(() =>
|
|
1254
|
+
storage.get(storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${entry.version}`))
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
if (ok && versionData && versionData.samples) {
|
|
1258
|
+
reconstructedSamples.push(...versionData.samples);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const targetEntry = historyData.history.find(e => e.version === targetVersion);
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
modelName,
|
|
1267
|
+
version: targetVersion,
|
|
1268
|
+
samples: reconstructedSamples,
|
|
1269
|
+
totalSamples: reconstructedSamples.length,
|
|
1270
|
+
features: modelConfig.features,
|
|
1271
|
+
target: modelConfig.target,
|
|
1272
|
+
metrics: targetEntry?.metrics,
|
|
1273
|
+
savedAt: targetEntry?.trainedAt
|
|
1274
|
+
};
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
console.error(`[MLPlugin] Failed to load training data for "${modelName}":`, error.message);
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Delete model from plugin storage (all versions)
|
|
636
1283
|
* @private
|
|
637
1284
|
*/
|
|
638
1285
|
async _deleteModel(modelName) {
|
|
639
1286
|
try {
|
|
640
1287
|
const storage = this.getStorage();
|
|
641
|
-
|
|
1288
|
+
const modelConfig = this.config.models[modelName];
|
|
1289
|
+
const resourceName = modelConfig.resource;
|
|
1290
|
+
const enableVersioning = this.config.enableVersioning;
|
|
1291
|
+
|
|
1292
|
+
if (enableVersioning) {
|
|
1293
|
+
// Delete all versions
|
|
1294
|
+
const versionInfo = this.modelVersions.get(modelName);
|
|
1295
|
+
if (versionInfo && versionInfo.latestVersion > 0) {
|
|
1296
|
+
for (let v = 1; v <= versionInfo.latestVersion; v++) {
|
|
1297
|
+
await storage.delete(storage.getPluginKey(resourceName, 'models', modelName, `v${v}`));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Delete metadata
|
|
1302
|
+
await storage.delete(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'));
|
|
1303
|
+
await storage.delete(storage.getPluginKey(resourceName, 'metadata', modelName, 'versions'));
|
|
1304
|
+
} else {
|
|
1305
|
+
// Delete non-versioned model
|
|
1306
|
+
await storage.delete(storage.getPluginKey(resourceName, 'models', modelName, 'latest'));
|
|
1307
|
+
}
|
|
642
1308
|
|
|
643
1309
|
if (this.config.verbose) {
|
|
644
|
-
console.log(`[MLPlugin] Deleted model "${modelName}" from plugin
|
|
1310
|
+
console.log(`[MLPlugin] Deleted model "${modelName}" from S3 (resource=${resourceName}/plugin=ml/models/${modelName}/)`);
|
|
645
1311
|
}
|
|
646
1312
|
} catch (error) {
|
|
647
1313
|
// Ignore errors (model might not exist)
|
|
@@ -650,6 +1316,302 @@ export class MLPlugin extends Plugin {
|
|
|
650
1316
|
}
|
|
651
1317
|
}
|
|
652
1318
|
}
|
|
653
|
-
}
|
|
654
1319
|
|
|
655
|
-
|
|
1320
|
+
/**
|
|
1321
|
+
* Delete training data from plugin storage (all versions)
|
|
1322
|
+
* @private
|
|
1323
|
+
*/
|
|
1324
|
+
async _deleteTrainingData(modelName) {
|
|
1325
|
+
try {
|
|
1326
|
+
const storage = this.getStorage();
|
|
1327
|
+
const modelConfig = this.config.models[modelName];
|
|
1328
|
+
const resourceName = modelConfig.resource;
|
|
1329
|
+
const enableVersioning = this.config.enableVersioning;
|
|
1330
|
+
|
|
1331
|
+
if (enableVersioning) {
|
|
1332
|
+
// Delete all version data
|
|
1333
|
+
const [ok, err, historyData] = await tryFn(() =>
|
|
1334
|
+
storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName))
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
if (ok && historyData && historyData.history) {
|
|
1338
|
+
for (const entry of historyData.history) {
|
|
1339
|
+
if (entry.storageKey) {
|
|
1340
|
+
await storage.delete(storage.getPluginKey(resourceName, 'training', 'data', modelName, `v${entry.version}`));
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Delete history
|
|
1346
|
+
await storage.delete(storage.getPluginKey(resourceName, 'training', 'history', modelName));
|
|
1347
|
+
} else {
|
|
1348
|
+
// Delete non-versioned training data
|
|
1349
|
+
await storage.delete(storage.getPluginKey(resourceName, 'training', 'data', modelName, 'latest'));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (this.config.verbose) {
|
|
1353
|
+
console.log(`[MLPlugin] Deleted training data for "${modelName}" from S3 (resource=${resourceName}/plugin=ml/training/)`);
|
|
1354
|
+
}
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
// Ignore errors (training data might not exist)
|
|
1357
|
+
if (this.config.verbose) {
|
|
1358
|
+
console.log(`[MLPlugin] Could not delete training data "${modelName}": ${error.message}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* List all versions of a model
|
|
1365
|
+
* @param {string} modelName - Model name
|
|
1366
|
+
* @returns {Array} List of version info
|
|
1367
|
+
*/
|
|
1368
|
+
async listModelVersions(modelName) {
|
|
1369
|
+
if (!this.config.enableVersioning) {
|
|
1370
|
+
throw new MLError('Versioning is not enabled', { modelName });
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
try {
|
|
1374
|
+
const storage = this.getStorage();
|
|
1375
|
+
const modelConfig = this.config.models[modelName];
|
|
1376
|
+
const resourceName = modelConfig.resource;
|
|
1377
|
+
const versionInfo = this.modelVersions.get(modelName) || { latestVersion: 0 };
|
|
1378
|
+
const versions = [];
|
|
1379
|
+
|
|
1380
|
+
// Load each version
|
|
1381
|
+
for (let v = 1; v <= versionInfo.latestVersion; v++) {
|
|
1382
|
+
const [ok, err, versionData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${v}`)));
|
|
1383
|
+
|
|
1384
|
+
if (ok && versionData) {
|
|
1385
|
+
versions.push({
|
|
1386
|
+
version: v,
|
|
1387
|
+
savedAt: versionData.savedAt,
|
|
1388
|
+
isCurrent: v === versionInfo.currentVersion,
|
|
1389
|
+
metrics: versionData.metrics
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
return versions;
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
console.error(`[MLPlugin] Failed to list versions for "${modelName}":`, error.message);
|
|
1397
|
+
return [];
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Load a specific version of a model
|
|
1403
|
+
* @param {string} modelName - Model name
|
|
1404
|
+
* @param {number} version - Version number
|
|
1405
|
+
*/
|
|
1406
|
+
async loadModelVersion(modelName, version) {
|
|
1407
|
+
if (!this.config.enableVersioning) {
|
|
1408
|
+
throw new MLError('Versioning is not enabled', { modelName });
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (!this.models[modelName]) {
|
|
1412
|
+
throw new ModelNotFoundError(`Model "${modelName}" not found`, { modelName });
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
try {
|
|
1416
|
+
const storage = this.getStorage();
|
|
1417
|
+
const modelConfig = this.config.models[modelName];
|
|
1418
|
+
const resourceName = modelConfig.resource;
|
|
1419
|
+
const [ok, err, versionData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version}`)));
|
|
1420
|
+
|
|
1421
|
+
if (!ok || !versionData) {
|
|
1422
|
+
throw new MLError(`Version ${version} not found for model "${modelName}"`, { modelName, version });
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
if (!versionData.modelData) {
|
|
1426
|
+
throw new MLError(`Model data not found in version ${version}`, { modelName, version });
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
await this.models[modelName].import(versionData.modelData);
|
|
1430
|
+
|
|
1431
|
+
// Update current version in memory (don't save to storage yet)
|
|
1432
|
+
const versionInfo = this.modelVersions.get(modelName);
|
|
1433
|
+
if (versionInfo) {
|
|
1434
|
+
versionInfo.currentVersion = version;
|
|
1435
|
+
this.modelVersions.set(modelName, versionInfo);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (this.config.verbose) {
|
|
1439
|
+
console.log(`[MLPlugin] Loaded model "${modelName}" v${version}`);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
return {
|
|
1443
|
+
version,
|
|
1444
|
+
metrics: versionData.metrics ? JSON.parse(versionData.metrics) : {},
|
|
1445
|
+
savedAt: versionData.savedAt
|
|
1446
|
+
};
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
console.error(`[MLPlugin] Failed to load version ${version} for "${modelName}":`, error.message);
|
|
1449
|
+
throw error;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Set active version for a model (used for predictions)
|
|
1455
|
+
* @param {string} modelName - Model name
|
|
1456
|
+
* @param {number} version - Version number
|
|
1457
|
+
*/
|
|
1458
|
+
async setActiveVersion(modelName, version) {
|
|
1459
|
+
if (!this.config.enableVersioning) {
|
|
1460
|
+
throw new MLError('Versioning is not enabled', { modelName });
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
const modelConfig = this.config.models[modelName];
|
|
1464
|
+
const resourceName = modelConfig.resource;
|
|
1465
|
+
|
|
1466
|
+
// Load the version into the model
|
|
1467
|
+
await this.loadModelVersion(modelName, version);
|
|
1468
|
+
|
|
1469
|
+
// Update version info in storage
|
|
1470
|
+
await this._updateVersionInfo(modelName, version);
|
|
1471
|
+
|
|
1472
|
+
// Update active reference
|
|
1473
|
+
const storage = this.getStorage();
|
|
1474
|
+
await storage.set(storage.getPluginKey(resourceName, 'metadata', modelName, 'active'), {
|
|
1475
|
+
modelName,
|
|
1476
|
+
version,
|
|
1477
|
+
type: 'reference',
|
|
1478
|
+
updatedAt: new Date().toISOString()
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
if (this.config.verbose) {
|
|
1482
|
+
console.log(`[MLPlugin] Set model "${modelName}" active version to v${version}`);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return { modelName, version };
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Get training history for a model
|
|
1490
|
+
* @param {string} modelName - Model name
|
|
1491
|
+
* @returns {Array} Training history
|
|
1492
|
+
*/
|
|
1493
|
+
async getTrainingHistory(modelName) {
|
|
1494
|
+
if (!this.config.enableVersioning) {
|
|
1495
|
+
// Fallback to legacy getTrainingData
|
|
1496
|
+
return await this.getTrainingData(modelName);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
try {
|
|
1500
|
+
const storage = this.getStorage();
|
|
1501
|
+
const modelConfig = this.config.models[modelName];
|
|
1502
|
+
const resourceName = modelConfig.resource;
|
|
1503
|
+
const [ok, err, historyData] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'training', 'history', modelName)));
|
|
1504
|
+
|
|
1505
|
+
if (!ok || !historyData) {
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return {
|
|
1510
|
+
modelName: historyData.modelName,
|
|
1511
|
+
totalTrainings: historyData.totalTrainings,
|
|
1512
|
+
latestVersion: historyData.latestVersion,
|
|
1513
|
+
history: JSON.parse(historyData.history),
|
|
1514
|
+
updatedAt: historyData.updatedAt
|
|
1515
|
+
};
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
console.error(`[MLPlugin] Failed to load training history for "${modelName}":`, error.message);
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Compare metrics between two versions
|
|
1524
|
+
* @param {string} modelName - Model name
|
|
1525
|
+
* @param {number} version1 - First version
|
|
1526
|
+
* @param {number} version2 - Second version
|
|
1527
|
+
* @returns {Object} Comparison results
|
|
1528
|
+
*/
|
|
1529
|
+
async compareVersions(modelName, version1, version2) {
|
|
1530
|
+
if (!this.config.enableVersioning) {
|
|
1531
|
+
throw new MLError('Versioning is not enabled', { modelName });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
try {
|
|
1535
|
+
const storage = this.getStorage();
|
|
1536
|
+
const modelConfig = this.config.models[modelName];
|
|
1537
|
+
const resourceName = modelConfig.resource;
|
|
1538
|
+
|
|
1539
|
+
const [ok1, err1, v1Data] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version1}`)));
|
|
1540
|
+
const [ok2, err2, v2Data] = await tryFn(() => storage.get(storage.getPluginKey(resourceName, 'models', modelName, `v${version2}`)));
|
|
1541
|
+
|
|
1542
|
+
if (!ok1 || !v1Data) {
|
|
1543
|
+
throw new MLError(`Version ${version1} not found`, { modelName, version: version1 });
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
if (!ok2 || !v2Data) {
|
|
1547
|
+
throw new MLError(`Version ${version2} not found`, { modelName, version: version2 });
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const metrics1 = v1Data.metrics ? JSON.parse(v1Data.metrics) : {};
|
|
1551
|
+
const metrics2 = v2Data.metrics ? JSON.parse(v2Data.metrics) : {};
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
modelName,
|
|
1555
|
+
version1: {
|
|
1556
|
+
version: version1,
|
|
1557
|
+
savedAt: v1Data.savedAt,
|
|
1558
|
+
metrics: metrics1
|
|
1559
|
+
},
|
|
1560
|
+
version2: {
|
|
1561
|
+
version: version2,
|
|
1562
|
+
savedAt: v2Data.savedAt,
|
|
1563
|
+
metrics: metrics2
|
|
1564
|
+
},
|
|
1565
|
+
improvement: {
|
|
1566
|
+
loss: metrics1.loss && metrics2.loss ? ((metrics1.loss - metrics2.loss) / metrics1.loss * 100).toFixed(2) + '%' : 'N/A',
|
|
1567
|
+
accuracy: metrics1.accuracy && metrics2.accuracy ? ((metrics2.accuracy - metrics1.accuracy) / metrics1.accuracy * 100).toFixed(2) + '%' : 'N/A'
|
|
1568
|
+
}
|
|
1569
|
+
};
|
|
1570
|
+
} catch (error) {
|
|
1571
|
+
console.error(`[MLPlugin] Failed to compare versions for "${modelName}":`, error.message);
|
|
1572
|
+
throw error;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Rollback to a previous version
|
|
1578
|
+
* @param {string} modelName - Model name
|
|
1579
|
+
* @param {number} version - Version to rollback to (defaults to previous version)
|
|
1580
|
+
* @returns {Object} Rollback info
|
|
1581
|
+
*/
|
|
1582
|
+
async rollbackVersion(modelName, version = null) {
|
|
1583
|
+
if (!this.config.enableVersioning) {
|
|
1584
|
+
throw new MLError('Versioning is not enabled', { modelName });
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
const versionInfo = this.modelVersions.get(modelName);
|
|
1588
|
+
if (!versionInfo) {
|
|
1589
|
+
throw new MLError(`No version info found for model "${modelName}"`, { modelName });
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// If no version specified, rollback to previous
|
|
1593
|
+
const targetVersion = version !== null ? version : Math.max(1, versionInfo.currentVersion - 1);
|
|
1594
|
+
|
|
1595
|
+
if (targetVersion === versionInfo.currentVersion) {
|
|
1596
|
+
throw new MLError('Cannot rollback to the same version', { modelName, version: targetVersion });
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
if (targetVersion < 1 || targetVersion > versionInfo.latestVersion) {
|
|
1600
|
+
throw new MLError(`Invalid version ${targetVersion}`, { modelName, version: targetVersion, latestVersion: versionInfo.latestVersion });
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Load and set as active
|
|
1604
|
+
const result = await this.setActiveVersion(modelName, targetVersion);
|
|
1605
|
+
|
|
1606
|
+
if (this.config.verbose) {
|
|
1607
|
+
console.log(`[MLPlugin] Rolled back model "${modelName}" from v${versionInfo.currentVersion} to v${targetVersion}`);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
return {
|
|
1611
|
+
modelName,
|
|
1612
|
+
previousVersion: versionInfo.currentVersion,
|
|
1613
|
+
currentVersion: targetVersion,
|
|
1614
|
+
...result
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
}
|