@myvillage/cli 2.3.0 → 2.4.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 +1 -1
- package/src/commands/dataset.js +43 -13
- package/src/index.js +5 -1
- package/src/utils/dataset-workspace.js +85 -21
package/package.json
CHANGED
package/src/commands/dataset.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function datasetAppendCommand(options) {
|
|
|
103
103
|
rows = readRowsFromFile(options.file);
|
|
104
104
|
} else if (options.text !== undefined || options.features !== undefined) {
|
|
105
105
|
const row = { split: options.split || 'train' };
|
|
106
|
-
row.features = options.features !== undefined ? parseFeatures(options.features) : options.text;
|
|
106
|
+
row.features = options.features !== undefined ? parseFeatures(options.features, manifest.modality) : options.text;
|
|
107
107
|
if (options.label !== undefined) row.label = options.label;
|
|
108
108
|
if (options.target !== undefined) row.target = Number(options.target);
|
|
109
109
|
rows = [row];
|
|
@@ -111,15 +111,27 @@ export async function datasetAppendCommand(options) {
|
|
|
111
111
|
throw new Error('Provide --image <path> (image datasets), --file <jsonl>, or --text/--features (with --label or --target).');
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
if (options.replace && !options.file) {
|
|
115
|
+
throw new Error('--replace only works with --file (it swaps in the whole working set).');
|
|
116
|
+
}
|
|
117
|
+
const featureNames = options.featureNames !== undefined ? parseNameList(options.featureNames) : undefined;
|
|
118
|
+
|
|
114
119
|
const spinner = options.json ? null : villageSpinner('Appending rows...').start();
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = appendRows(slug, rows, { source: options.source || 'cli' }, {
|
|
123
|
+
replace: Boolean(options.replace),
|
|
124
|
+
featureNames,
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
if (spinner) spinner.stop();
|
|
128
|
+
}
|
|
117
129
|
|
|
118
130
|
if (options.json) {
|
|
119
131
|
console.log(JSON.stringify(result, null, 2));
|
|
120
132
|
return;
|
|
121
133
|
}
|
|
122
|
-
success(
|
|
134
|
+
success(`${result.replaced ? 'Replaced the working set with' : 'Appended'} ${result.appended} row(s) ${result.replaced ? 'in' : 'to'} ${slug}`);
|
|
123
135
|
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
124
136
|
} catch (e) {
|
|
125
137
|
error(e.message);
|
|
@@ -133,8 +145,13 @@ export async function datasetShowCommand(slug, options) {
|
|
|
133
145
|
try {
|
|
134
146
|
const manifest = readManifest(slug);
|
|
135
147
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
136
|
-
|
|
137
|
-
const
|
|
148
|
+
// --limit 0 returns every row (used by Studio's export).
|
|
149
|
+
const limit = options.limit !== undefined ? parseInt(options.limit, 10) : 5;
|
|
150
|
+
const version = options.frozen !== undefined ? parseInt(options.frozen, 10) : undefined;
|
|
151
|
+
if (version !== undefined && !manifest.versions.some((v) => v.version === version)) {
|
|
152
|
+
throw new Error(`Dataset "${slug}" has no frozen v${options.frozen}.`);
|
|
153
|
+
}
|
|
154
|
+
const sample = readRows(slug, { limit, version });
|
|
138
155
|
if (options.json) {
|
|
139
156
|
console.log(JSON.stringify({ manifest, sample }, null, 2));
|
|
140
157
|
return;
|
|
@@ -206,7 +223,9 @@ export async function datasetRowUpdateCommand(options) {
|
|
|
206
223
|
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
207
224
|
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
208
225
|
const updates = {};
|
|
209
|
-
if (options.features !== undefined)
|
|
226
|
+
if (options.features !== undefined) {
|
|
227
|
+
updates.features = parseFeatures(options.features, readManifest(slug)?.modality);
|
|
228
|
+
}
|
|
210
229
|
else if (options.text !== undefined) updates.features = options.text;
|
|
211
230
|
if (options.label !== undefined) updates.label = options.label;
|
|
212
231
|
if (options.target !== undefined) updates.target = Number(options.target);
|
|
@@ -244,8 +263,22 @@ function readRowsFromFile(path) {
|
|
|
244
263
|
return rows;
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
|
|
248
|
-
|
|
266
|
+
/** A JSON array of names, or a comma-separated list. */
|
|
267
|
+
function parseNameList(raw) {
|
|
268
|
+
const trimmed = String(raw).trim();
|
|
269
|
+
if (trimmed.startsWith('[')) {
|
|
270
|
+
const parsed = JSON.parse(trimmed);
|
|
271
|
+
if (!Array.isArray(parsed)) throw new Error('--feature-names must be a list.');
|
|
272
|
+
return parsed.map(String);
|
|
273
|
+
}
|
|
274
|
+
return trimmed.split(',').map((s) => s.trim());
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseFeatures(raw, modality) {
|
|
278
|
+
// Tabular features are a numeric vector, passed as JSON ("[12, 45]") or a
|
|
279
|
+
// comma list ("12, 45" or a single "12"). Anything that isn't a number stays
|
|
280
|
+
// NaN so row validation rejects it rather than storing it as text.
|
|
281
|
+
if (modality !== 'tabular') return raw;
|
|
249
282
|
const trimmed = raw.trim();
|
|
250
283
|
if (trimmed.startsWith('[')) {
|
|
251
284
|
try {
|
|
@@ -254,10 +287,7 @@ function parseFeatures(raw) {
|
|
|
254
287
|
/* fall through */
|
|
255
288
|
}
|
|
256
289
|
}
|
|
257
|
-
|
|
258
|
-
return trimmed.split(',').map((n) => Number(n.trim()));
|
|
259
|
-
}
|
|
260
|
-
return raw;
|
|
290
|
+
return trimmed.split(',').map((n) => (n.trim() === '' ? NaN : Number(n.trim())));
|
|
261
291
|
}
|
|
262
292
|
|
|
263
293
|
function truncate(s, n) {
|
package/src/index.js
CHANGED
|
@@ -1010,13 +1010,17 @@ export function run() {
|
|
|
1010
1010
|
.option('--label <label>', 'Class label (classification)')
|
|
1011
1011
|
.option('--target <number>', 'Numeric target (regression)')
|
|
1012
1012
|
.option('--split <split>', 'train | val', 'train')
|
|
1013
|
+
.option('--replace', 'With --file: replace the working set instead of adding to it')
|
|
1014
|
+
.option('--source <source>', 'Provenance source stamped on each row (e.g. import:csv)')
|
|
1015
|
+
.option('--feature-names <list>', 'Tabular column names (JSON array or comma-separated)')
|
|
1013
1016
|
.option('--json', 'Output raw JSON')
|
|
1014
1017
|
.action(datasetAppendCommand);
|
|
1015
1018
|
|
|
1016
1019
|
datasetCmd
|
|
1017
1020
|
.command('show <slug>')
|
|
1018
1021
|
.description('Show a dataset manifest and a sample of rows')
|
|
1019
|
-
.option('--limit <n>', 'Number of sample rows to show', '5')
|
|
1022
|
+
.option('--limit <n>', 'Number of sample rows to show (0 = all)', '5')
|
|
1023
|
+
.option('--frozen <n>', 'Read frozen version n instead of the working set')
|
|
1020
1024
|
.option('--json', 'Output raw JSON')
|
|
1021
1025
|
.action(datasetShowCommand);
|
|
1022
1026
|
|
|
@@ -202,23 +202,50 @@ export function createDataset({ name, modality, taskType = 'classification', lab
|
|
|
202
202
|
* Append rows to a dataset's working set, stamping provenance, validating
|
|
203
203
|
* against the dataset's modality/task, and updating manifest stats.
|
|
204
204
|
*
|
|
205
|
+
* Every row is validated before anything is written, so a bad row leaves the
|
|
206
|
+
* dataset untouched. With `replace`, the working set is overwritten instead of
|
|
207
|
+
* extended (frozen versions are never touched) and the label list is rebuilt
|
|
208
|
+
* from the new rows. `featureNames` names the columns of a tabular vector
|
|
209
|
+
* (e.g. from a spreadsheet header); once set, every tabular row must have
|
|
210
|
+
* exactly that many numbers.
|
|
211
|
+
*
|
|
205
212
|
* @param {string} slug
|
|
206
213
|
* @param {Array<{features:any, label?:string, target?:number, split?:string}>} rows
|
|
207
214
|
* @param {object} provenance partial provenance ({ source, agentId, villagerId, villageId })
|
|
208
|
-
* @
|
|
215
|
+
* @param {{replace?:boolean, featureNames?:string[]}} opts
|
|
216
|
+
* @returns {{datasetId:string, appended:number, replaced:boolean, stats:object}}
|
|
209
217
|
*/
|
|
210
|
-
export function appendRows(slug, rows, provenance = {}) {
|
|
218
|
+
export function appendRows(slug, rows, provenance = {}, { replace = false, featureNames } = {}) {
|
|
211
219
|
const manifest = readManifest(slug);
|
|
212
220
|
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
213
221
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
214
222
|
throw new Error('No rows to append.');
|
|
215
223
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
if (featureNames !== undefined) {
|
|
225
|
+
applyFeatureNames(manifest, featureNames, replace);
|
|
226
|
+
} else if (replace) {
|
|
227
|
+
delete manifest.featureNames;
|
|
228
|
+
}
|
|
219
229
|
|
|
220
|
-
|
|
221
|
-
|
|
230
|
+
const existing = replace ? [] : readRows(slug);
|
|
231
|
+
// Without named columns, hold tabular rows to the width already on disk
|
|
232
|
+
// (the trainer needs every vector the same length).
|
|
233
|
+
let width = manifest.featureNames?.length;
|
|
234
|
+
if (width == null && manifest.modality === 'tabular') {
|
|
235
|
+
const first = existing.find((r) => Array.isArray(r.features));
|
|
236
|
+
if (first) width = first.features.length;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const source = provenance.source || 'cli';
|
|
240
|
+
const newRows = [];
|
|
241
|
+
rows.forEach((raw, i) => {
|
|
242
|
+
let row;
|
|
243
|
+
try {
|
|
244
|
+
row = normalizeRow(raw, manifest, width);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
throw new Error(rows.length > 1 ? `Row ${i + 1}: ${e.message}` : e.message);
|
|
247
|
+
}
|
|
248
|
+
if (width == null && Array.isArray(row.features)) width = row.features.length;
|
|
222
249
|
row.id = randomUUID();
|
|
223
250
|
row.provenance = {
|
|
224
251
|
source,
|
|
@@ -227,24 +254,47 @@ export function appendRows(slug, rows, provenance = {}) {
|
|
|
227
254
|
...(provenance.villageId ? { villageId: provenance.villageId } : {}),
|
|
228
255
|
ts: nowISO(),
|
|
229
256
|
};
|
|
230
|
-
|
|
257
|
+
newRows.push(row);
|
|
258
|
+
});
|
|
231
259
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
manifest.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
260
|
+
if (replace) manifest.labels = [];
|
|
261
|
+
for (const row of newRows) {
|
|
262
|
+
if (row.label != null && !manifest.labels.includes(row.label)) manifest.labels.push(row.label);
|
|
263
|
+
}
|
|
264
|
+
if (replace) {
|
|
265
|
+
writeWorkingSet(slug, manifest, newRows);
|
|
266
|
+
} else {
|
|
267
|
+
appendFileSync(getCurrentDataFile(slug), newRows.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
268
|
+
manifest.stats = recomputeStats([...existing, ...newRows]);
|
|
269
|
+
writeManifest(slug, manifest);
|
|
240
270
|
}
|
|
271
|
+
return { datasetId: slug, appended: newRows.length, replaced: replace, stats: manifest.stats };
|
|
272
|
+
}
|
|
241
273
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return
|
|
274
|
+
/** Swap the whole working set for `rows` (e.g. a spreadsheet import). */
|
|
275
|
+
export function replaceRows(slug, rows, provenance = {}, { featureNames } = {}) {
|
|
276
|
+
return appendRows(slug, rows, provenance, { replace: true, featureNames });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Set a tabular dataset's column names, refusing a mismatch on append. */
|
|
280
|
+
function applyFeatureNames(manifest, featureNames, replace) {
|
|
281
|
+
if (manifest.modality !== 'tabular') {
|
|
282
|
+
throw new Error('Feature names only apply to tabular datasets.');
|
|
283
|
+
}
|
|
284
|
+
const names = featureNames.map((n) => String(n).trim());
|
|
285
|
+
if (names.length === 0 || names.some((n) => !n)) {
|
|
286
|
+
throw new Error('Feature names must be non-empty.');
|
|
287
|
+
}
|
|
288
|
+
const current = manifest.featureNames;
|
|
289
|
+
if (!replace && current?.length && current.join('\u0000') !== names.join('\u0000')) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`These columns don't match the dataset. It expects: ${current.join(', ')}.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
manifest.featureNames = names;
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
function normalizeRow(raw, manifest) {
|
|
297
|
+
function normalizeRow(raw, manifest, width) {
|
|
248
298
|
if (raw == null || typeof raw !== 'object') {
|
|
249
299
|
throw new Error('Each row must be an object.');
|
|
250
300
|
}
|
|
@@ -266,6 +316,18 @@ function normalizeRow(raw, manifest) {
|
|
|
266
316
|
);
|
|
267
317
|
}
|
|
268
318
|
}
|
|
319
|
+
if (manifest.modality === 'tabular') {
|
|
320
|
+
const f = raw.features;
|
|
321
|
+
if (!Array.isArray(f) || f.length === 0 || f.some((n) => typeof n !== 'number' || !Number.isFinite(n))) {
|
|
322
|
+
throw new Error('Tabular rows need features that are all numbers (e.g. [12, 45, 8]).');
|
|
323
|
+
}
|
|
324
|
+
if (width != null && f.length !== width) {
|
|
325
|
+
throw new Error(`Expected ${width} number(s) in each row, got ${f.length}.`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (manifest.modality === 'text' && (typeof raw.features !== 'string' || !raw.features.trim())) {
|
|
329
|
+
throw new Error('Text rows need some text.');
|
|
330
|
+
}
|
|
269
331
|
const row = { features: raw.features, split };
|
|
270
332
|
if (manifest.taskType === 'classification') {
|
|
271
333
|
if (raw.label == null || String(raw.label).length === 0) {
|
|
@@ -273,7 +335,7 @@ function normalizeRow(raw, manifest) {
|
|
|
273
335
|
}
|
|
274
336
|
row.label = String(raw.label);
|
|
275
337
|
} else {
|
|
276
|
-
if (raw.target == null || Number.
|
|
338
|
+
if (raw.target == null || raw.target === '' || !Number.isFinite(Number(raw.target))) {
|
|
277
339
|
throw new Error('Regression rows require a numeric "target".');
|
|
278
340
|
}
|
|
279
341
|
row.target = Number(raw.target);
|
|
@@ -376,6 +438,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
376
438
|
if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
|
|
377
439
|
throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
|
|
378
440
|
}
|
|
441
|
+
const sibling = rows.find((r, i) => i !== idx && Array.isArray(r.features));
|
|
379
442
|
const merged = normalizeRow(
|
|
380
443
|
{
|
|
381
444
|
features: updates.features !== undefined ? updates.features : existing.features,
|
|
@@ -384,6 +447,7 @@ export function updateRow(slug, rowId, updates = {}) {
|
|
|
384
447
|
target: updates.target !== undefined ? updates.target : existing.target,
|
|
385
448
|
},
|
|
386
449
|
manifest,
|
|
450
|
+
manifest.featureNames?.length ?? sibling?.features.length,
|
|
387
451
|
);
|
|
388
452
|
merged.id = existing.id;
|
|
389
453
|
merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
|