@myvillage/cli 1.72.0 → 1.74.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/package.json +4 -1
- package/src/agent-runtime/builtin-tools-registry.js +6 -3
- package/src/agent-runtime/builtin-tools.js +22 -4
- package/src/commands/dataset.js +70 -3
- package/src/commands/model.js +9 -3
- package/src/index.js +26 -1
- package/src/utils/dataset-workspace.js +140 -1
- package/src/utils/image-backbone.js +144 -0
- package/src/utils/model-trainer.js +39 -12
- package/vendor/backbones/mobilenet-v3-small/SOURCE.md +13 -0
- package/vendor/backbones/mobilenet-v3-small/group1-shard1of2.bin +0 -0
- package/vendor/backbones/mobilenet-v3-small/group1-shard2of2.bin +0 -0
- package/vendor/backbones/mobilenet-v3-small/model.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.74.0",
|
|
4
4
|
"description": "MyVillageOS CLI for community developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin/",
|
|
14
14
|
"src/",
|
|
15
15
|
"tools/",
|
|
16
|
+
"vendor/",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
|
18
19
|
"keywords": [
|
|
@@ -37,9 +38,11 @@
|
|
|
37
38
|
"commander": "^11.1.0",
|
|
38
39
|
"conf": "^12.0.0",
|
|
39
40
|
"inquirer": "^9.2.12",
|
|
41
|
+
"jpeg-js": "^0.4.4",
|
|
40
42
|
"open": "^10.0.3",
|
|
41
43
|
"ora": "^8.0.1",
|
|
42
44
|
"p-limit": "^5.0.0",
|
|
45
|
+
"pngjs": "^7.0.0",
|
|
43
46
|
"tar": "^7.5.16",
|
|
44
47
|
"update-notifier": "^7.0.0",
|
|
45
48
|
"yaml": "^2.3.0",
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
export const DATASET_APPEND_DESCRIPTION =
|
|
15
15
|
'Append labeled training example(s) to a LOCAL dataset on this machine. ' +
|
|
16
16
|
'The dataset must already exist (created via the Studio Lab or ' +
|
|
17
|
-
'`myvillage dataset create`).
|
|
17
|
+
'`myvillage dataset create`). `features` is text, a numeric vector ' +
|
|
18
|
+
'(tabular), or a local image file path (image datasets — the file is ' +
|
|
19
|
+
'copied into the dataset). For classification provide `label`; for ' +
|
|
18
20
|
'regression provide a numeric `target`. Data stays local; provenance ' +
|
|
19
21
|
'is recorded automatically.';
|
|
20
22
|
|
|
21
23
|
export const MODEL_PREDICT_DESCRIPTION =
|
|
22
|
-
'Run a prediction with a LOCAL trained model on this machine. ' +
|
|
23
|
-
'
|
|
24
|
+
'Run a prediction with a LOCAL trained model on this machine. `input` is ' +
|
|
25
|
+
'text, a numeric vector (tabular), or a local image file path (image ' +
|
|
26
|
+
'models). Returns the predicted class with per-class confidence scores ' +
|
|
24
27
|
'(classification) or a numeric value (regression). The model must already ' +
|
|
25
28
|
'exist (trained in the Studio Lab or via `myvillage model train`).';
|
|
26
29
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { tool } from 'ai';
|
|
16
16
|
import { z } from 'zod';
|
|
17
|
-
import { appendRows, datasetExists } from '../utils/dataset-workspace.js';
|
|
17
|
+
import { appendRows, datasetExists, readManifest, ingestAsset } from '../utils/dataset-workspace.js';
|
|
18
18
|
import { DATASET_APPEND_DESCRIPTION, MODEL_PREDICT_DESCRIPTION } from './builtin-tools-registry.js';
|
|
19
19
|
|
|
20
20
|
export { BUILTIN_TOOL_DEFS } from './builtin-tools-registry.js';
|
|
@@ -36,7 +36,10 @@ export function getBuiltinTools(agentName, config) {
|
|
|
36
36
|
z.object({
|
|
37
37
|
features: z
|
|
38
38
|
.union([z.string(), z.array(z.number())])
|
|
39
|
-
.describe(
|
|
39
|
+
.describe(
|
|
40
|
+
'Text content; a numeric vector for tabular data; or, for image ' +
|
|
41
|
+
'datasets, an absolute path to a local image file (.jpg/.png).',
|
|
42
|
+
),
|
|
40
43
|
label: z.string().optional().describe('Class label (classification datasets)'),
|
|
41
44
|
target: z.number().optional().describe('Numeric target (regression datasets)'),
|
|
42
45
|
split: z.enum(['train', 'val']).optional(),
|
|
@@ -57,7 +60,19 @@ export function getBuiltinTools(agentName, config) {
|
|
|
57
60
|
const provenance = { source: `agent:${agentName}` };
|
|
58
61
|
const agentId = config?.man?.village_agent_id;
|
|
59
62
|
if (agentId) provenance.agentId = agentId;
|
|
60
|
-
|
|
63
|
+
// For image datasets, the agent passes a file path; ingest each into
|
|
64
|
+
// the dataset's assets/ and swap in the resulting AssetRef before the
|
|
65
|
+
// shared appendRows() runs (same path the CLI --image flow takes).
|
|
66
|
+
const manifest = readManifest(datasetId);
|
|
67
|
+
let toAppend = rows;
|
|
68
|
+
if (manifest?.modality === 'image') {
|
|
69
|
+
toAppend = rows.map((r) => {
|
|
70
|
+
const path = typeof r.features === 'string' ? r.features : r.features?.path;
|
|
71
|
+
if (!path) throw new Error('Image rows need a local image file path in "features".');
|
|
72
|
+
return { ...r, features: ingestAsset(datasetId, path, 'image') };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const result = appendRows(datasetId, toAppend, provenance);
|
|
61
76
|
return {
|
|
62
77
|
ok: true,
|
|
63
78
|
datasetId: result.datasetId,
|
|
@@ -78,7 +93,10 @@ export function getBuiltinTools(agentName, config) {
|
|
|
78
93
|
modelId: z.string().describe('Slug of the local trained model'),
|
|
79
94
|
input: z
|
|
80
95
|
.union([z.string(), z.array(z.number())])
|
|
81
|
-
.describe(
|
|
96
|
+
.describe(
|
|
97
|
+
'Text; a numeric vector for tabular models; or, for image models, ' +
|
|
98
|
+
'an absolute path to a local image file (.jpg/.png).',
|
|
99
|
+
),
|
|
82
100
|
}),
|
|
83
101
|
execute: async ({ modelId, input }) => {
|
|
84
102
|
try {
|
package/src/commands/dataset.js
CHANGED
|
@@ -16,8 +16,11 @@ import {
|
|
|
16
16
|
readManifest,
|
|
17
17
|
datasetExists,
|
|
18
18
|
appendRows,
|
|
19
|
+
ingestAsset,
|
|
19
20
|
readRows,
|
|
20
21
|
freezeVersion,
|
|
22
|
+
deleteRow,
|
|
23
|
+
updateRow,
|
|
21
24
|
} from '../utils/dataset-workspace.js';
|
|
22
25
|
|
|
23
26
|
// ── create ─────────────────────────────────────────────
|
|
@@ -83,8 +86,20 @@ export async function datasetAppendCommand(options) {
|
|
|
83
86
|
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
84
87
|
if (!datasetExists(slug)) throw new Error(`Dataset "${slug}" not found.`);
|
|
85
88
|
|
|
89
|
+
const manifest = readManifest(slug);
|
|
90
|
+
|
|
86
91
|
let rows;
|
|
87
|
-
if (options.
|
|
92
|
+
if (options.image) {
|
|
93
|
+
// Copy the image into the dataset's assets/ and reference it by AssetRef.
|
|
94
|
+
if (manifest.modality !== 'image') {
|
|
95
|
+
throw new Error(`--image is only valid for image datasets (this one is "${manifest.modality}").`);
|
|
96
|
+
}
|
|
97
|
+
const asset = ingestAsset(slug, options.image, 'image');
|
|
98
|
+
const row = { split: options.split || 'train', features: asset };
|
|
99
|
+
if (options.label !== undefined) row.label = options.label;
|
|
100
|
+
if (options.target !== undefined) row.target = Number(options.target);
|
|
101
|
+
rows = [row];
|
|
102
|
+
} else if (options.file) {
|
|
88
103
|
rows = readRowsFromFile(options.file);
|
|
89
104
|
} else if (options.text !== undefined || options.features !== undefined) {
|
|
90
105
|
const row = { split: options.split || 'train' };
|
|
@@ -93,7 +108,7 @@ export async function datasetAppendCommand(options) {
|
|
|
93
108
|
if (options.target !== undefined) row.target = Number(options.target);
|
|
94
109
|
rows = [row];
|
|
95
110
|
} else {
|
|
96
|
-
throw new Error('Provide
|
|
111
|
+
throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
|
|
97
112
|
}
|
|
98
113
|
|
|
99
114
|
const spinner = options.json ? null : villageSpinner('Appending rows...').start();
|
|
@@ -132,7 +147,10 @@ export async function datasetShowCommand(slug, options) {
|
|
|
132
147
|
if (sample.length) {
|
|
133
148
|
console.log(brand.cream(`\n sample (${sample.length}):`));
|
|
134
149
|
for (const r of sample) {
|
|
135
|
-
const feat =
|
|
150
|
+
const feat =
|
|
151
|
+
typeof r.features === 'string' ? truncate(r.features, 50)
|
|
152
|
+
: r.features?.path ? `📎 ${r.features.path}`
|
|
153
|
+
: JSON.stringify(r.features);
|
|
136
154
|
const tgt = r.label != null ? `label=${r.label}` : `target=${r.target}`;
|
|
137
155
|
console.log(` [${r.split}] ${tgt} ${brand.teal(feat)}`);
|
|
138
156
|
}
|
|
@@ -160,6 +178,55 @@ export async function datasetFreezeCommand(slug, options) {
|
|
|
160
178
|
}
|
|
161
179
|
}
|
|
162
180
|
|
|
181
|
+
// ── delete a row ───────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function datasetRowDeleteCommand(options) {
|
|
184
|
+
try {
|
|
185
|
+
const slug = options.dataset;
|
|
186
|
+
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
187
|
+
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
188
|
+
const result = deleteRow(slug, options.id);
|
|
189
|
+
if (options.json) {
|
|
190
|
+
console.log(JSON.stringify(result, null, 2));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
success(`Deleted row ${options.id} from ${slug}`);
|
|
194
|
+
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
error(e.message);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── update a row ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export async function datasetRowUpdateCommand(options) {
|
|
204
|
+
try {
|
|
205
|
+
const slug = options.dataset;
|
|
206
|
+
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
207
|
+
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
208
|
+
const updates = {};
|
|
209
|
+
if (options.features !== undefined) updates.features = parseFeatures(options.features);
|
|
210
|
+
else if (options.text !== undefined) updates.features = options.text;
|
|
211
|
+
if (options.label !== undefined) updates.label = options.label;
|
|
212
|
+
if (options.target !== undefined) updates.target = Number(options.target);
|
|
213
|
+
if (options.split !== undefined) updates.split = options.split;
|
|
214
|
+
if (Object.keys(updates).length === 0) {
|
|
215
|
+
throw new Error('Nothing to update. Pass --text/--features, --label, --target, or --split.');
|
|
216
|
+
}
|
|
217
|
+
const result = updateRow(slug, options.id, updates);
|
|
218
|
+
if (options.json) {
|
|
219
|
+
console.log(JSON.stringify(result, null, 2));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
success(`Updated row ${options.id} in ${slug}`);
|
|
223
|
+
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
error(e.message);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
163
230
|
// ── helpers ────────────────────────────────────────────
|
|
164
231
|
|
|
165
232
|
function readRowsFromFile(path) {
|
package/src/commands/model.js
CHANGED
|
@@ -57,7 +57,8 @@ export async function modelTrainCommand(options) {
|
|
|
57
57
|
const m = record.metrics;
|
|
58
58
|
if (m.accuracy != null) info(` accuracy: ${(m.accuracy * 100).toFixed(1)}% over ${m.classes} classes`);
|
|
59
59
|
if (m.mae != null) info(` MAE: ${m.mae} · R²: ${m.r2}`);
|
|
60
|
-
|
|
60
|
+
const testFlag = manifest.modality === 'image' ? '--image <path>' : '--input "..."';
|
|
61
|
+
info(` test it: myvillage model predict ${manifest.slug} ${testFlag}`);
|
|
61
62
|
} catch (e) {
|
|
62
63
|
if (options.json) process.stdout.write(JSON.stringify({ type: 'error', error: e.message }) + '\n');
|
|
63
64
|
else error(e.message);
|
|
@@ -121,10 +122,15 @@ export async function modelShowCommand(slug, options) {
|
|
|
121
122
|
export async function modelPredictCommand(slug, options) {
|
|
122
123
|
try {
|
|
123
124
|
if (!modelExists(slug)) throw new Error(`Model "${slug}" not found.`);
|
|
124
|
-
if (options.input === undefined) throw new Error('--input <value> is required.');
|
|
125
125
|
const record = readModelManifest(slug);
|
|
126
|
+
// Image models take a file path (--image); text/tabular take --input.
|
|
127
|
+
const isImage = record.modality === 'image';
|
|
128
|
+
const input = isImage ? options.image : options.input;
|
|
129
|
+
if (input === undefined) {
|
|
130
|
+
throw new Error(isImage ? '--image <path> is required for image models.' : '--input <value> is required.');
|
|
131
|
+
}
|
|
126
132
|
const model = await loadModel(slug);
|
|
127
|
-
const result = predictWithModel(model, record,
|
|
133
|
+
const result = await predictWithModel(model, record, input);
|
|
128
134
|
if (options.json) {
|
|
129
135
|
console.log(JSON.stringify(result, null, 2));
|
|
130
136
|
return;
|
package/src/index.js
CHANGED
|
@@ -124,6 +124,8 @@ import {
|
|
|
124
124
|
datasetAppendCommand,
|
|
125
125
|
datasetShowCommand,
|
|
126
126
|
datasetFreezeCommand,
|
|
127
|
+
datasetRowDeleteCommand,
|
|
128
|
+
datasetRowUpdateCommand,
|
|
127
129
|
} from './commands/dataset.js';
|
|
128
130
|
import {
|
|
129
131
|
modelTrainCommand,
|
|
@@ -877,6 +879,7 @@ export function run() {
|
|
|
877
879
|
.description('Append labeled example(s) to a dataset')
|
|
878
880
|
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
879
881
|
.option('--file <path>', 'JSONL file of rows ({features,label|target,split})')
|
|
882
|
+
.option('--image <path>', 'Image file to add (image datasets); copied into the dataset')
|
|
880
883
|
.option('--text <text>', 'Single-row text features')
|
|
881
884
|
.option('--features <value>', 'Single-row features (string, or numeric vector for tabular)')
|
|
882
885
|
.option('--label <label>', 'Class label (classification)')
|
|
@@ -899,6 +902,27 @@ export function run() {
|
|
|
899
902
|
.option('--json', 'Output raw JSON')
|
|
900
903
|
.action(datasetFreezeCommand);
|
|
901
904
|
|
|
905
|
+
datasetCmd
|
|
906
|
+
.command('rm-row')
|
|
907
|
+
.description('Delete a row from the working set (by id)')
|
|
908
|
+
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
909
|
+
.requiredOption('--id <rowId>', 'Row id to delete')
|
|
910
|
+
.option('--json', 'Output raw JSON')
|
|
911
|
+
.action(datasetRowDeleteCommand);
|
|
912
|
+
|
|
913
|
+
datasetCmd
|
|
914
|
+
.command('update-row')
|
|
915
|
+
.description('Update a working-set row (by id)')
|
|
916
|
+
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
917
|
+
.requiredOption('--id <rowId>', 'Row id to update')
|
|
918
|
+
.option('--text <text>', 'New text features')
|
|
919
|
+
.option('--features <value>', 'New features (string, or numeric vector for tabular)')
|
|
920
|
+
.option('--label <label>', 'New class label (classification)')
|
|
921
|
+
.option('--target <number>', 'New numeric target (regression)')
|
|
922
|
+
.option('--split <split>', 'train | val')
|
|
923
|
+
.option('--json', 'Output raw JSON')
|
|
924
|
+
.action(datasetRowUpdateCommand);
|
|
925
|
+
|
|
902
926
|
// ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
|
|
903
927
|
const modelCmd = program
|
|
904
928
|
.command('model')
|
|
@@ -928,7 +952,8 @@ export function run() {
|
|
|
928
952
|
modelCmd
|
|
929
953
|
.command('predict <slug>')
|
|
930
954
|
.description('Run a prediction with a trained model')
|
|
931
|
-
.
|
|
955
|
+
.option('--input <value>', 'Input: text, or a comma-separated numeric vector for tabular')
|
|
956
|
+
.option('--image <path>', 'Image file to classify (image models)')
|
|
932
957
|
.option('--json', 'Output raw JSON')
|
|
933
958
|
.action(modelPredictCommand);
|
|
934
959
|
|
|
@@ -25,8 +25,9 @@ import {
|
|
|
25
25
|
appendFileSync,
|
|
26
26
|
readdirSync,
|
|
27
27
|
copyFileSync,
|
|
28
|
+
statSync,
|
|
28
29
|
} from 'fs';
|
|
29
|
-
import { join } from 'path';
|
|
30
|
+
import { join, extname, basename } from 'path';
|
|
30
31
|
import { randomUUID } from 'crypto';
|
|
31
32
|
import { getConfigDir } from './config.js';
|
|
32
33
|
|
|
@@ -54,6 +55,46 @@ export function getAssetsDir(slug) {
|
|
|
54
55
|
return dir;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** Resolve an AssetRef's dataset-relative path to an absolute path on disk. */
|
|
59
|
+
export function getAssetPath(slug, relPath) {
|
|
60
|
+
return join(getAssetsDir(slug), relPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const IMAGE_MIME = {
|
|
64
|
+
'.jpg': 'image/jpeg',
|
|
65
|
+
'.jpeg': 'image/jpeg',
|
|
66
|
+
'.png': 'image/png',
|
|
67
|
+
};
|
|
68
|
+
const AUDIO_MIME = { '.wav': 'audio/wav' };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Copy a media file into a dataset's assets/ dir and return an AssetRef.
|
|
72
|
+
* Raw media stays local (never leaves the machine) — this is the on-disk side
|
|
73
|
+
* of image/audio collection. The stored path is relative to assets/ so the
|
|
74
|
+
* dataset dir stays portable.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} slug
|
|
77
|
+
* @param {string} srcPath absolute path to the source media file
|
|
78
|
+
* @param {'image'|'audio'} kind
|
|
79
|
+
* @returns {{kind:string, path:string, mime?:string, bytes:number}}
|
|
80
|
+
*/
|
|
81
|
+
export function ingestAsset(slug, srcPath, kind) {
|
|
82
|
+
if (!existsSync(srcPath)) throw new Error(`File not found: ${srcPath}`);
|
|
83
|
+
const ext = extname(srcPath).toLowerCase();
|
|
84
|
+
const mimeTable = kind === 'audio' ? AUDIO_MIME : IMAGE_MIME;
|
|
85
|
+
const mime = mimeTable[ext];
|
|
86
|
+
if (!mime) {
|
|
87
|
+
const ok = Object.keys(mimeTable).join(', ');
|
|
88
|
+
throw new Error(`Unsupported ${kind} type "${ext}". Supported: ${ok}.`);
|
|
89
|
+
}
|
|
90
|
+
// Unique, collision-proof filename; keep a hint of the original name.
|
|
91
|
+
const stem = basename(srcPath, ext).replace(/[^a-z0-9_-]+/gi, '-').slice(0, 32) || kind;
|
|
92
|
+
const fileName = `${stem}-${randomUUID().slice(0, 8)}${ext}`;
|
|
93
|
+
const destAbs = join(getAssetsDir(slug), fileName);
|
|
94
|
+
copyFileSync(srcPath, destAbs);
|
|
95
|
+
return { kind, path: fileName, mime, bytes: statSync(destAbs).size };
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
function getManifestPath(slug) {
|
|
58
99
|
return join(getDatasetDir(slug), 'manifest.json');
|
|
59
100
|
}
|
|
@@ -214,6 +255,17 @@ function normalizeRow(raw, manifest) {
|
|
|
214
255
|
if (raw.features === undefined || raw.features === null) {
|
|
215
256
|
throw new Error('Each row needs a "features" value.');
|
|
216
257
|
}
|
|
258
|
+
// image/audio rows carry an AssetRef {kind, path}; the caller ingests the
|
|
259
|
+
// file (see ingestAsset) before appending, so by here features is the ref.
|
|
260
|
+
if (manifest.modality === 'image' || manifest.modality === 'audio') {
|
|
261
|
+
const f = raw.features;
|
|
262
|
+
if (typeof f !== 'object' || !f.path) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${manifest.modality} rows need an ingested asset reference {kind, path}. ` +
|
|
265
|
+
'Append with --image <path> (CLI) or pass an image path to the tool.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
217
269
|
const row = { features: raw.features, split };
|
|
218
270
|
if (manifest.taskType === 'classification') {
|
|
219
271
|
if (raw.label == null || String(raw.label).length === 0) {
|
|
@@ -256,6 +308,93 @@ export function readRows(slug, { version, split, limit } = {}) {
|
|
|
256
308
|
return out;
|
|
257
309
|
}
|
|
258
310
|
|
|
311
|
+
// ── Edit / delete working-set rows ─────────────────────
|
|
312
|
+
//
|
|
313
|
+
// Only the working set (current.jsonl) is mutable; frozen versions (v*.jsonl)
|
|
314
|
+
// are immutable snapshots and are never touched here. Both ops rewrite the
|
|
315
|
+
// whole working-set file and recompute manifest stats from the result, so the
|
|
316
|
+
// manifest can never drift from the rows on disk.
|
|
317
|
+
|
|
318
|
+
/** Recompute manifest stats (rowCount / bySplit / byLabel) from a row array. */
|
|
319
|
+
function recomputeStats(rows) {
|
|
320
|
+
const stats = emptyStats();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
stats.rowCount += 1;
|
|
323
|
+
if (stats.bySplit[row.split] != null) stats.bySplit[row.split] += 1;
|
|
324
|
+
if (row.label != null) {
|
|
325
|
+
stats.byLabel[row.label] = (stats.byLabel[row.label] || 0) + 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return stats;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Overwrite the working set with `rows` and refresh the manifest's stats. */
|
|
332
|
+
function writeWorkingSet(slug, manifest, rows) {
|
|
333
|
+
const body = rows.map((r) => JSON.stringify(r)).join('\n');
|
|
334
|
+
writeFileSync(getCurrentDataFile(slug), rows.length ? body + '\n' : '');
|
|
335
|
+
manifest.stats = recomputeStats(rows);
|
|
336
|
+
return writeManifest(slug, manifest);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Delete one working-set row by id. Throws if the id isn't in current.jsonl
|
|
341
|
+
* (e.g. it only exists in a frozen version, which can't be edited).
|
|
342
|
+
* @returns {{datasetId:string, deleted:string, stats:object}}
|
|
343
|
+
*/
|
|
344
|
+
export function deleteRow(slug, rowId) {
|
|
345
|
+
const manifest = readManifest(slug);
|
|
346
|
+
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
347
|
+
if (!rowId) throw new Error('A row id is required.');
|
|
348
|
+
const rows = readRows(slug);
|
|
349
|
+
const idx = rows.findIndex((r) => r.id === rowId);
|
|
350
|
+
if (idx === -1) {
|
|
351
|
+
throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
|
|
352
|
+
}
|
|
353
|
+
const [removed] = rows.splice(idx, 1);
|
|
354
|
+
writeWorkingSet(slug, manifest, rows);
|
|
355
|
+
return { datasetId: slug, deleted: removed.id, stats: manifest.stats };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Update one working-set row by id. `updates` is a partial
|
|
360
|
+
* {features, label, target, split}; the merged row is re-validated against the
|
|
361
|
+
* dataset's modality/task (same rules as append). The row id is preserved and
|
|
362
|
+
* provenance.editedAt is stamped. Image/audio asset refs can't be edited here —
|
|
363
|
+
* delete the row and re-add the asset instead.
|
|
364
|
+
* @returns {{datasetId:string, updated:string, stats:object}}
|
|
365
|
+
*/
|
|
366
|
+
export function updateRow(slug, rowId, updates = {}) {
|
|
367
|
+
const manifest = readManifest(slug);
|
|
368
|
+
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
369
|
+
if (!rowId) throw new Error('A row id is required.');
|
|
370
|
+
const rows = readRows(slug);
|
|
371
|
+
const idx = rows.findIndex((r) => r.id === rowId);
|
|
372
|
+
if (idx === -1) {
|
|
373
|
+
throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
|
|
374
|
+
}
|
|
375
|
+
const existing = rows[idx];
|
|
376
|
+
if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
|
|
377
|
+
throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
|
|
378
|
+
}
|
|
379
|
+
const merged = normalizeRow(
|
|
380
|
+
{
|
|
381
|
+
features: updates.features !== undefined ? updates.features : existing.features,
|
|
382
|
+
split: updates.split !== undefined ? updates.split : existing.split,
|
|
383
|
+
label: updates.label !== undefined ? updates.label : existing.label,
|
|
384
|
+
target: updates.target !== undefined ? updates.target : existing.target,
|
|
385
|
+
},
|
|
386
|
+
manifest,
|
|
387
|
+
);
|
|
388
|
+
merged.id = existing.id;
|
|
389
|
+
merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
|
|
390
|
+
if (merged.label != null && !manifest.labels.includes(merged.label)) {
|
|
391
|
+
manifest.labels.push(merged.label);
|
|
392
|
+
}
|
|
393
|
+
rows[idx] = merged;
|
|
394
|
+
writeWorkingSet(slug, manifest, rows);
|
|
395
|
+
return { datasetId: slug, updated: merged.id, stats: manifest.stats };
|
|
396
|
+
}
|
|
397
|
+
|
|
259
398
|
// ── Freeze a version ───────────────────────────────────
|
|
260
399
|
|
|
261
400
|
/**
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image feature-extractor for the Studio "Lab" pillar.
|
|
3
|
+
*
|
|
4
|
+
* Turns an image file into a fixed-length embedding using a MobileNetV3-Small
|
|
5
|
+
* backbone that is **bundled inside this package** (vendor/backbones/) and
|
|
6
|
+
* loaded from disk — never downloaded — so the Studio stays "the only thing you
|
|
7
|
+
* install" (see ed-platform docs/architecture/studio-models-pillar.md §0b, §13).
|
|
8
|
+
*
|
|
9
|
+
* Pipeline: decode (pure-JS jpeg-js / pngjs, no native binary) → RGB tensor →
|
|
10
|
+
* resize 224×224 → normalize to [0,1] → MobileNetV3-Small → 1024-d embedding.
|
|
11
|
+
*
|
|
12
|
+
* The embedding then feeds the SAME dense head the text/tabular trainer uses;
|
|
13
|
+
* image only adds this featurization step. tfjs is imported lazily so listing
|
|
14
|
+
* datasets/models never pulls the heavy runtime in.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync } from 'fs';
|
|
17
|
+
import { join, dirname, extname } from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { createRequire } from 'module';
|
|
20
|
+
|
|
21
|
+
// CommonJS interop for the pure-JS decoders (this module is ESM).
|
|
22
|
+
const _require = createRequire(import.meta.url);
|
|
23
|
+
function requirePkg(name) {
|
|
24
|
+
return _require(name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const BACKBONE_ID = 'mobilenet-v3-small';
|
|
28
|
+
export const INPUT_SIZE = 224;
|
|
29
|
+
export const EMBED_DIM = 1024;
|
|
30
|
+
|
|
31
|
+
const BACKBONE_DIR = join(
|
|
32
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
33
|
+
'..',
|
|
34
|
+
'..',
|
|
35
|
+
'vendor',
|
|
36
|
+
'backbones',
|
|
37
|
+
BACKBONE_ID,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
let _backbonePromise = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A tf.io.IOHandler that reads the vendored GraphModel (model.json + weight
|
|
44
|
+
* shards) straight off disk. We can't use tf.loadGraphModel('file://…') because
|
|
45
|
+
* the bundled pure-JS @tensorflow/tfjs has no file:// handler (that lives in
|
|
46
|
+
* tfjs-node); reading the artifacts ourselves keeps it native-binary-free —
|
|
47
|
+
* the same trick model-workspace.js uses for saved models.
|
|
48
|
+
*/
|
|
49
|
+
function diskGraphHandler(dir) {
|
|
50
|
+
return {
|
|
51
|
+
load: async () => {
|
|
52
|
+
const manifestPath = join(dir, 'model.json');
|
|
53
|
+
if (!existsSync(manifestPath)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Bundled image backbone not found at ${dir}. The MobileNet weights ship ` +
|
|
56
|
+
'inside @myvillage/cli (vendor/backbones/); reinstall the CLI if this is missing.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const j = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
60
|
+
const specs = [];
|
|
61
|
+
const buffers = [];
|
|
62
|
+
for (const grp of j.weightsManifest) {
|
|
63
|
+
for (const p of grp.paths) buffers.push(readFileSync(join(dir, p)));
|
|
64
|
+
specs.push(...grp.weights);
|
|
65
|
+
}
|
|
66
|
+
const concat = Buffer.concat(buffers);
|
|
67
|
+
// Slice to the exact backing range so tfjs sees a clean ArrayBuffer.
|
|
68
|
+
const weightData = concat.buffer.slice(
|
|
69
|
+
concat.byteOffset,
|
|
70
|
+
concat.byteOffset + concat.byteLength,
|
|
71
|
+
);
|
|
72
|
+
return {
|
|
73
|
+
modelTopology: j.modelTopology,
|
|
74
|
+
weightSpecs: specs,
|
|
75
|
+
weightData,
|
|
76
|
+
format: j.format,
|
|
77
|
+
generatedBy: j.generatedBy,
|
|
78
|
+
convertedBy: j.convertedBy,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Load (once) and cache the bundled backbone GraphModel. */
|
|
85
|
+
async function getBackbone() {
|
|
86
|
+
if (!_backbonePromise) {
|
|
87
|
+
_backbonePromise = (async () => {
|
|
88
|
+
const tf = await import('@tensorflow/tfjs');
|
|
89
|
+
await tf.setBackend('cpu');
|
|
90
|
+
await tf.ready();
|
|
91
|
+
return tf.loadGraphModel(diskGraphHandler(BACKBONE_DIR));
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
return _backbonePromise;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Decode an image file to {width, height, data:RGBA Uint8Array} (pure JS). */
|
|
98
|
+
function decodeImage(path) {
|
|
99
|
+
if (!existsSync(path)) throw new Error(`Image not found: ${path}`);
|
|
100
|
+
const buf = readFileSync(path);
|
|
101
|
+
const ext = extname(path).toLowerCase();
|
|
102
|
+
if (ext === '.png') {
|
|
103
|
+
// Lazy require keeps these decoders out of the hot path for text/tabular.
|
|
104
|
+
const { PNG } = requirePkg('pngjs');
|
|
105
|
+
const png = PNG.sync.read(buf);
|
|
106
|
+
return { width: png.width, height: png.height, data: png.data };
|
|
107
|
+
}
|
|
108
|
+
if (ext === '.jpg' || ext === '.jpeg') {
|
|
109
|
+
const jpeg = requirePkg('jpeg-js');
|
|
110
|
+
const img = jpeg.decode(buf, { useTArray: true, maxMemoryUsageInMB: 512 });
|
|
111
|
+
return { width: img.width, height: img.height, data: img.data };
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`Unsupported image type "${ext}". Use .jpg, .jpeg, or .png.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Embed a single image file → Float array of length EMBED_DIM (1024).
|
|
118
|
+
* Used by both training (featurize) and prediction (featurizeOne).
|
|
119
|
+
*/
|
|
120
|
+
export async function embedImage(path) {
|
|
121
|
+
const tf = await import('@tensorflow/tfjs');
|
|
122
|
+
const model = await getBackbone();
|
|
123
|
+
const { width, height, data } = decodeImage(path);
|
|
124
|
+
|
|
125
|
+
return tf.tidy(() => {
|
|
126
|
+
// RGBA → RGB float [0,1], shaped [h, w, 3], resized to the backbone input.
|
|
127
|
+
const rgb = new Float32Array(width * height * 3);
|
|
128
|
+
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
|
|
129
|
+
rgb[j] = data[i] / 255;
|
|
130
|
+
rgb[j + 1] = data[i + 1] / 255;
|
|
131
|
+
rgb[j + 2] = data[i + 2] / 255;
|
|
132
|
+
}
|
|
133
|
+
const img = tf.tensor3d(rgb, [height, width, 3]);
|
|
134
|
+
const resized = tf.image.resizeBilinear(img, [INPUT_SIZE, INPUT_SIZE]);
|
|
135
|
+
const batched = resized.expandDims(0); // [1, 224, 224, 3]
|
|
136
|
+
const out = model.predict(batched); // [1, 1024]
|
|
137
|
+
return Array.from(out.dataSync());
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Featurization params persisted in the model record for image models. */
|
|
142
|
+
export function imageFeaturization() {
|
|
143
|
+
return { modality: 'image', dim: EMBED_DIM, backbone: BACKBONE_ID, inputSize: INPUT_SIZE };
|
|
144
|
+
}
|