voyageai-cli 1.20.6 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -26
- package/README.md +130 -2
- package/package.json +1 -1
- package/src/cli.js +8 -0
- package/src/commands/eval.js +420 -10
- package/src/commands/generate.js +220 -0
- package/src/commands/playground.js +93 -0
- package/src/commands/purge.js +271 -0
- package/src/commands/refresh.js +322 -0
- package/src/commands/scaffold.js +217 -0
- package/src/lib/codegen.js +313 -0
- package/src/lib/explanations.js +155 -0
- package/src/lib/scaffold-structure.js +114 -0
- package/src/lib/templates/nextjs/README.md.tpl +106 -0
- package/src/lib/templates/nextjs/env.example.tpl +8 -0
- package/src/lib/templates/nextjs/layout.jsx.tpl +29 -0
- package/src/lib/templates/nextjs/lib-mongo.js.tpl +111 -0
- package/src/lib/templates/nextjs/lib-voyage.js.tpl +103 -0
- package/src/lib/templates/nextjs/package.json.tpl +33 -0
- package/src/lib/templates/nextjs/page-search.jsx.tpl +147 -0
- package/src/lib/templates/nextjs/route-ingest.js.tpl +114 -0
- package/src/lib/templates/nextjs/route-search.js.tpl +97 -0
- package/src/lib/templates/nextjs/theme.js.tpl +84 -0
- package/src/lib/templates/python/README.md.tpl +145 -0
- package/src/lib/templates/python/app.py.tpl +221 -0
- package/src/lib/templates/python/chunker.py.tpl +127 -0
- package/src/lib/templates/python/env.example.tpl +12 -0
- package/src/lib/templates/python/mongo_client.py.tpl +125 -0
- package/src/lib/templates/python/requirements.txt.tpl +10 -0
- package/src/lib/templates/python/voyage_client.py.tpl +124 -0
- package/src/lib/templates/vanilla/README.md.tpl +156 -0
- package/src/lib/templates/vanilla/client.js.tpl +103 -0
- package/src/lib/templates/vanilla/connection.js.tpl +126 -0
- package/src/lib/templates/vanilla/env.example.tpl +11 -0
- package/src/lib/templates/vanilla/ingest.js.tpl +231 -0
- package/src/lib/templates/vanilla/package.json.tpl +31 -0
- package/src/lib/templates/vanilla/retrieval.js.tpl +100 -0
- package/src/lib/templates/vanilla/search-api.js.tpl +175 -0
- package/src/lib/templates/vanilla/server.js.tpl +81 -0
- package/src/lib/zip.js +130 -0
- package/src/playground/index.html +519 -3
package/src/commands/eval.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
4
5
|
const { getDefaultModel, DEFAULT_RERANK_MODEL, MODEL_CATALOG } = require('../lib/catalog');
|
|
5
6
|
const { generateEmbeddings, apiRequest } = require('../lib/api');
|
|
6
7
|
const { getMongoCollection } = require('../lib/mongo');
|
|
@@ -8,6 +9,105 @@ const { loadProject } = require('../lib/project');
|
|
|
8
9
|
const { computeMetrics, aggregateMetrics } = require('../lib/metrics');
|
|
9
10
|
const ui = require('../lib/ui');
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Save evaluation results to a JSON file.
|
|
14
|
+
* @param {string} filePath - Output path
|
|
15
|
+
* @param {object} results - Results object to save
|
|
16
|
+
*/
|
|
17
|
+
function saveResults(filePath, results) {
|
|
18
|
+
const output = {
|
|
19
|
+
...results,
|
|
20
|
+
savedAt: new Date().toISOString(),
|
|
21
|
+
vaiVersion: require('../../package.json').version,
|
|
22
|
+
};
|
|
23
|
+
fs.writeFileSync(filePath, JSON.stringify(output, null, 2), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load baseline results from a JSON file.
|
|
28
|
+
* @param {string} filePath - Input path
|
|
29
|
+
* @returns {object} Loaded results
|
|
30
|
+
*/
|
|
31
|
+
function loadBaseline(filePath) {
|
|
32
|
+
if (!fs.existsSync(filePath)) {
|
|
33
|
+
throw new Error(`Baseline file not found: ${filePath}`);
|
|
34
|
+
}
|
|
35
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
36
|
+
return JSON.parse(content);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute deltas between current and baseline results.
|
|
41
|
+
* @param {object} current - Current aggregated metrics
|
|
42
|
+
* @param {object} baseline - Baseline aggregated metrics
|
|
43
|
+
* @returns {object} Deltas with direction indicators
|
|
44
|
+
*/
|
|
45
|
+
function computeDeltas(current, baseline) {
|
|
46
|
+
const deltas = {};
|
|
47
|
+
for (const key of Object.keys(current)) {
|
|
48
|
+
if (baseline[key] !== undefined) {
|
|
49
|
+
const diff = current[key] - baseline[key];
|
|
50
|
+
const pctChange = baseline[key] !== 0
|
|
51
|
+
? ((diff / baseline[key]) * 100).toFixed(1)
|
|
52
|
+
: (diff > 0 ? '+∞' : diff < 0 ? '-∞' : '0');
|
|
53
|
+
deltas[key] = {
|
|
54
|
+
current: current[key],
|
|
55
|
+
baseline: baseline[key],
|
|
56
|
+
diff,
|
|
57
|
+
pctChange,
|
|
58
|
+
improved: diff > 0.001,
|
|
59
|
+
regressed: diff < -0.001,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return deltas;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Print comparison between current and baseline results.
|
|
68
|
+
* @param {object} deltas - Delta object from computeDeltas
|
|
69
|
+
*/
|
|
70
|
+
function printBaselineComparison(deltas) {
|
|
71
|
+
console.log('');
|
|
72
|
+
console.log(ui.bold('Comparison with baseline:'));
|
|
73
|
+
console.log('');
|
|
74
|
+
|
|
75
|
+
const metricKeys = Object.keys(deltas);
|
|
76
|
+
const maxKeyLen = Math.max(...metricKeys.map(k => k.length));
|
|
77
|
+
|
|
78
|
+
for (const key of metricKeys) {
|
|
79
|
+
const d = deltas[key];
|
|
80
|
+
const label = key.toUpperCase().padEnd(maxKeyLen + 1);
|
|
81
|
+
const currentStr = d.current.toFixed(4);
|
|
82
|
+
const baselineStr = d.baseline.toFixed(4);
|
|
83
|
+
|
|
84
|
+
let diffStr;
|
|
85
|
+
if (d.improved) {
|
|
86
|
+
diffStr = ui.green(`+${d.diff.toFixed(4)} (${d.pctChange}%)`);
|
|
87
|
+
} else if (d.regressed) {
|
|
88
|
+
diffStr = ui.red(`${d.diff.toFixed(4)} (${d.pctChange}%)`);
|
|
89
|
+
} else {
|
|
90
|
+
diffStr = ui.dim(`${d.diff >= 0 ? '+' : ''}${d.diff.toFixed(4)} (${d.pctChange}%)`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(` ${label} ${currentStr} vs ${baselineStr} ${diffStr}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Summary
|
|
97
|
+
const improved = Object.values(deltas).filter(d => d.improved).length;
|
|
98
|
+
const regressed = Object.values(deltas).filter(d => d.regressed).length;
|
|
99
|
+
const unchanged = metricKeys.length - improved - regressed;
|
|
100
|
+
|
|
101
|
+
console.log('');
|
|
102
|
+
if (improved > regressed) {
|
|
103
|
+
console.log(ui.success(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
|
|
104
|
+
} else if (regressed > improved) {
|
|
105
|
+
console.log(ui.warn(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
|
|
106
|
+
} else {
|
|
107
|
+
console.log(ui.dim(` Overall: ${improved} improved, ${regressed} regressed, ${unchanged} unchanged`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
11
111
|
/**
|
|
12
112
|
* Load a test set from a JSONL file.
|
|
13
113
|
*
|
|
@@ -61,9 +161,14 @@ function loadTestSet(filePath, mode = 'retrieval') {
|
|
|
61
161
|
* @param {import('commander').Command} program
|
|
62
162
|
*/
|
|
63
163
|
function registerEval(program) {
|
|
64
|
-
program
|
|
164
|
+
const evalCmd = program
|
|
65
165
|
.command('eval')
|
|
66
|
-
.description('Evaluate retrieval & reranking quality — MRR, NDCG, Recall on your data')
|
|
166
|
+
.description('Evaluate retrieval & reranking quality — MRR, NDCG, Recall on your data');
|
|
167
|
+
|
|
168
|
+
// Register compare subcommand
|
|
169
|
+
registerEvalCompare(evalCmd);
|
|
170
|
+
|
|
171
|
+
evalCmd
|
|
67
172
|
.requiredOption('--test-set <path>', 'JSONL file with queries and expected results')
|
|
68
173
|
.option('--mode <mode>', 'Evaluation mode: "retrieval" (default) or "rerank"', 'retrieval')
|
|
69
174
|
.option('--db <database>', 'Database name (retrieval mode)')
|
|
@@ -82,6 +187,8 @@ function registerEval(program) {
|
|
|
82
187
|
.option('--text-field <name>', 'Document text field', 'text')
|
|
83
188
|
.option('--id-field <name>', 'Document ID field for matching (default: _id)', '_id')
|
|
84
189
|
.option('--compare <configs>', 'Compare configs: "model1,model2" or "rerank,no-rerank"')
|
|
190
|
+
.option('--save <path>', 'Save results to JSON file for later comparison')
|
|
191
|
+
.option('--baseline <path>', 'Compare against baseline results from previous run')
|
|
85
192
|
.option('--json', 'Machine-readable JSON output')
|
|
86
193
|
.option('-q, --quiet', 'Suppress non-essential output')
|
|
87
194
|
.action(async (opts) => {
|
|
@@ -241,14 +348,43 @@ function registerEval(program) {
|
|
|
241
348
|
const sorted = [...perQueryResults].sort((a, b) => a.metrics.mrr - b.metrics.mrr);
|
|
242
349
|
const worstQueries = sorted.slice(0, Math.min(3, sorted.length));
|
|
243
350
|
|
|
351
|
+
// Build results object for saving/comparison
|
|
352
|
+
const resultsObj = {
|
|
353
|
+
mode: 'retrieval',
|
|
354
|
+
config: { model, rerank: doRerank, rerankModel: doRerank ? rerankModel : null, db, collection, kValues },
|
|
355
|
+
summary: aggregated,
|
|
356
|
+
tokens: { embed: totalEmbedTokens, rerank: totalRerankTokens },
|
|
357
|
+
queries: perQueryResults.length,
|
|
358
|
+
perQuery: perQueryResults,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Save results if --save specified
|
|
362
|
+
if (opts.save) {
|
|
363
|
+
saveResults(opts.save, resultsObj);
|
|
364
|
+
if (verbose) {
|
|
365
|
+
console.log(ui.success(`Results saved to ${opts.save}`));
|
|
366
|
+
console.log('');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Load and compare with baseline if --baseline specified
|
|
371
|
+
let baseline = null;
|
|
372
|
+
let deltas = null;
|
|
373
|
+
if (opts.baseline) {
|
|
374
|
+
try {
|
|
375
|
+
baseline = loadBaseline(opts.baseline);
|
|
376
|
+
deltas = computeDeltas(aggregated, baseline.summary);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
console.error(ui.warn(`Could not load baseline: ${err.message}`));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
244
382
|
if (opts.json) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
perQuery: perQueryResults,
|
|
251
|
-
}, null, 2));
|
|
383
|
+
if (deltas) {
|
|
384
|
+
resultsObj.baseline = { path: opts.baseline, savedAt: baseline.savedAt };
|
|
385
|
+
resultsObj.deltas = deltas;
|
|
386
|
+
}
|
|
387
|
+
console.log(JSON.stringify(resultsObj, null, 2));
|
|
252
388
|
return;
|
|
253
389
|
}
|
|
254
390
|
|
|
@@ -285,6 +421,11 @@ function registerEval(program) {
|
|
|
285
421
|
console.log('');
|
|
286
422
|
console.log(ui.dim(` ${testSet.length} queries evaluated | Tokens: embed ${totalEmbedTokens}${totalRerankTokens ? `, rerank ${totalRerankTokens}` : ''}`));
|
|
287
423
|
|
|
424
|
+
// Print baseline comparison if available
|
|
425
|
+
if (deltas) {
|
|
426
|
+
printBaselineComparison(deltas);
|
|
427
|
+
}
|
|
428
|
+
|
|
288
429
|
// Suggestions
|
|
289
430
|
const mrr = aggregated.mrr;
|
|
290
431
|
const recall5 = aggregated['r@5'];
|
|
@@ -618,4 +759,273 @@ function renderBar(value, width) {
|
|
|
618
759
|
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
619
760
|
}
|
|
620
761
|
|
|
621
|
-
|
|
762
|
+
/**
|
|
763
|
+
* Register the eval compare subcommand.
|
|
764
|
+
* Compares multiple configurations side-by-side on the same test set.
|
|
765
|
+
*
|
|
766
|
+
* @param {import('commander').Command} evalCmd - The eval command to add compare to
|
|
767
|
+
*/
|
|
768
|
+
function registerEvalCompare(evalCmd) {
|
|
769
|
+
evalCmd
|
|
770
|
+
.command('compare')
|
|
771
|
+
.description('Compare multiple configurations on the same test set')
|
|
772
|
+
.requiredOption('--test-set <path>', 'JSONL file with queries and expected results')
|
|
773
|
+
.requiredOption('--configs <paths>', 'Comma-separated paths to config JSON files')
|
|
774
|
+
.option('--mode <mode>', 'Evaluation mode: "retrieval" (default) or "rerank"', 'retrieval')
|
|
775
|
+
.option('-k, --k-values <values>', 'Comma-separated K values for @K metrics', '1,3,5,10')
|
|
776
|
+
.option('--save <path>', 'Save comparison results to JSON file')
|
|
777
|
+
.option('--json', 'Machine-readable JSON output')
|
|
778
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
779
|
+
.action(async (opts) => {
|
|
780
|
+
try {
|
|
781
|
+
const configPaths = opts.configs.split(',').map(p => p.trim());
|
|
782
|
+
const kValues = opts.kValues.split(',').map(v => parseInt(v.trim(), 10)).filter(v => !isNaN(v));
|
|
783
|
+
const verbose = !opts.json && !opts.quiet;
|
|
784
|
+
|
|
785
|
+
// Load config files
|
|
786
|
+
const configs = [];
|
|
787
|
+
for (const configPath of configPaths) {
|
|
788
|
+
if (!fs.existsSync(configPath)) {
|
|
789
|
+
console.error(ui.error(`Config file not found: ${configPath}`));
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
793
|
+
const config = JSON.parse(content);
|
|
794
|
+
config._path = configPath;
|
|
795
|
+
configs.push(config);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Load test set
|
|
799
|
+
let testSet;
|
|
800
|
+
try {
|
|
801
|
+
testSet = loadTestSet(opts.testSet, opts.mode);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error(ui.error(`Failed to load test set: ${err.message}`));
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (testSet.length === 0) {
|
|
808
|
+
console.error(ui.error('Test set is empty.'));
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (verbose) {
|
|
813
|
+
console.log('');
|
|
814
|
+
console.log(ui.bold('📊 Configuration Comparison'));
|
|
815
|
+
console.log(ui.dim(` Test set: ${testSet.length} queries`));
|
|
816
|
+
console.log(ui.dim(` Configs: ${configs.map(c => c.name || c._path).join(', ')}`));
|
|
817
|
+
console.log(ui.dim(` Mode: ${opts.mode}`));
|
|
818
|
+
console.log('');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Run eval for each config
|
|
822
|
+
const results = [];
|
|
823
|
+
|
|
824
|
+
for (const config of configs) {
|
|
825
|
+
const configName = config.name || path.basename(config._path, '.json');
|
|
826
|
+
|
|
827
|
+
if (verbose) {
|
|
828
|
+
console.log(ui.dim(` Evaluating: ${configName}...`));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (opts.mode === 'rerank') {
|
|
832
|
+
// Rerank mode
|
|
833
|
+
const model = config.model || config.rerankModel || DEFAULT_RERANK_MODEL;
|
|
834
|
+
const topK = config.topK;
|
|
835
|
+
|
|
836
|
+
const perQueryResults = [];
|
|
837
|
+
let totalTokens = 0;
|
|
838
|
+
|
|
839
|
+
for (const testCase of testSet) {
|
|
840
|
+
const rerankResult = await apiRequest('/rerank', {
|
|
841
|
+
query: testCase.query,
|
|
842
|
+
documents: testCase.documents,
|
|
843
|
+
model,
|
|
844
|
+
...(topK ? { top_k: topK } : {}),
|
|
845
|
+
});
|
|
846
|
+
totalTokens += rerankResult.usage?.total_tokens || 0;
|
|
847
|
+
|
|
848
|
+
const relevantIdSet = new Set(testCase.relevant.map(idx => `doc_${idx}`));
|
|
849
|
+
const rerankedItems = rerankResult.data || [];
|
|
850
|
+
const retrievedIds = rerankedItems.map(item => `doc_${item.index}`);
|
|
851
|
+
const metrics = computeMetrics(retrievedIds, [...relevantIdSet], kValues);
|
|
852
|
+
|
|
853
|
+
perQueryResults.push({ metrics });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const aggregated = aggregateMetrics(perQueryResults.map(r => r.metrics));
|
|
857
|
+
results.push({
|
|
858
|
+
name: configName,
|
|
859
|
+
config,
|
|
860
|
+
summary: aggregated,
|
|
861
|
+
tokens: totalTokens,
|
|
862
|
+
queries: testSet.length,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
} else {
|
|
866
|
+
// Retrieval mode
|
|
867
|
+
const { config: proj } = loadProject();
|
|
868
|
+
const model = config.model || proj.model || getDefaultModel();
|
|
869
|
+
const db = config.db || proj.db;
|
|
870
|
+
const collection = config.collection || proj.collection;
|
|
871
|
+
const index = config.index || proj.index || 'vector_index';
|
|
872
|
+
const field = config.field || proj.field || 'embedding';
|
|
873
|
+
const doRerank = config.rerank !== false;
|
|
874
|
+
const rerankModel = config.rerankModel || DEFAULT_RERANK_MODEL;
|
|
875
|
+
const dimensions = config.dimensions || proj.dimensions;
|
|
876
|
+
const limit = config.limit || 20;
|
|
877
|
+
|
|
878
|
+
if (!db || !collection) {
|
|
879
|
+
console.error(ui.error(`Config ${configName} missing db/collection.`));
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const { client, collection: coll } = await getMongoCollection(db, collection);
|
|
884
|
+
const perQueryResults = [];
|
|
885
|
+
let totalEmbedTokens = 0;
|
|
886
|
+
let totalRerankTokens = 0;
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
for (const testCase of testSet) {
|
|
890
|
+
const embedOpts = { model, inputType: 'query' };
|
|
891
|
+
if (dimensions) embedOpts.dimensions = dimensions;
|
|
892
|
+
const embedResult = await generateEmbeddings([testCase.query], embedOpts);
|
|
893
|
+
const queryVector = embedResult.data[0].embedding;
|
|
894
|
+
totalEmbedTokens += embedResult.usage?.total_tokens || 0;
|
|
895
|
+
|
|
896
|
+
const numCandidates = Math.min(limit * 15, 10000);
|
|
897
|
+
const pipeline = [
|
|
898
|
+
{ $vectorSearch: { index, path: field, queryVector, numCandidates, limit } },
|
|
899
|
+
{ $addFields: { _vsScore: { $meta: 'vectorSearchScore' } } },
|
|
900
|
+
];
|
|
901
|
+
|
|
902
|
+
let searchResults = await coll.aggregate(pipeline).toArray();
|
|
903
|
+
|
|
904
|
+
if (doRerank && searchResults.length > 1) {
|
|
905
|
+
const documents = searchResults.map(doc => String(doc.text || doc));
|
|
906
|
+
const rerankResult = await apiRequest('/rerank', {
|
|
907
|
+
query: testCase.query,
|
|
908
|
+
documents,
|
|
909
|
+
model: rerankModel,
|
|
910
|
+
});
|
|
911
|
+
totalRerankTokens += rerankResult.usage?.total_tokens || 0;
|
|
912
|
+
searchResults = (rerankResult.data || []).map(item => searchResults[item.index]);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const retrievedIds = searchResults.map(doc => String(doc._id));
|
|
916
|
+
const metrics = computeMetrics(retrievedIds, testCase.relevant, kValues);
|
|
917
|
+
perQueryResults.push({ metrics });
|
|
918
|
+
}
|
|
919
|
+
} finally {
|
|
920
|
+
await client.close();
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const aggregated = aggregateMetrics(perQueryResults.map(r => r.metrics));
|
|
924
|
+
results.push({
|
|
925
|
+
name: configName,
|
|
926
|
+
config,
|
|
927
|
+
summary: aggregated,
|
|
928
|
+
tokens: { embed: totalEmbedTokens, rerank: totalRerankTokens },
|
|
929
|
+
queries: testSet.length,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Save if requested
|
|
935
|
+
if (opts.save) {
|
|
936
|
+
const output = {
|
|
937
|
+
mode: opts.mode,
|
|
938
|
+
testSet: opts.testSet,
|
|
939
|
+
kValues,
|
|
940
|
+
configs: results,
|
|
941
|
+
comparedAt: new Date().toISOString(),
|
|
942
|
+
vaiVersion: require('../../package.json').version,
|
|
943
|
+
};
|
|
944
|
+
fs.writeFileSync(opts.save, JSON.stringify(output, null, 2), 'utf8');
|
|
945
|
+
if (verbose) {
|
|
946
|
+
console.log(ui.success(`Comparison saved to ${opts.save}`));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// JSON output
|
|
951
|
+
if (opts.json) {
|
|
952
|
+
console.log(JSON.stringify({
|
|
953
|
+
mode: opts.mode,
|
|
954
|
+
testSet: opts.testSet,
|
|
955
|
+
kValues,
|
|
956
|
+
configs: results,
|
|
957
|
+
}, null, 2));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Pretty output - comparison table
|
|
962
|
+
console.log('');
|
|
963
|
+
console.log(ui.bold('Configuration Comparison'));
|
|
964
|
+
console.log('');
|
|
965
|
+
|
|
966
|
+
const keyMetrics = ['mrr', 'ndcg@5', 'ndcg@10', 'r@5', 'r@10'];
|
|
967
|
+
const availableMetrics = keyMetrics.filter(k => results[0].summary[k] !== undefined);
|
|
968
|
+
|
|
969
|
+
// Find best per metric
|
|
970
|
+
const bestPerMetric = {};
|
|
971
|
+
for (const m of availableMetrics) {
|
|
972
|
+
bestPerMetric[m] = Math.max(...results.map(r => r.summary[m]));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Header
|
|
976
|
+
const nameColW = Math.max(20, ...results.map(r => r.name.length + 2));
|
|
977
|
+
const header = ` ${'Config'.padEnd(nameColW)} ${availableMetrics.map(m => m.toUpperCase().padStart(10)).join('')}`;
|
|
978
|
+
console.log(ui.dim(header));
|
|
979
|
+
console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
|
|
980
|
+
|
|
981
|
+
// Rows
|
|
982
|
+
for (const result of results) {
|
|
983
|
+
const cols = availableMetrics.map(m => {
|
|
984
|
+
const val = result.summary[m];
|
|
985
|
+
const str = val.toFixed(4);
|
|
986
|
+
return val === bestPerMetric[m] ? ui.green(str.padStart(10)) : str.padStart(10);
|
|
987
|
+
}).join('');
|
|
988
|
+
console.log(` ${result.name.padEnd(nameColW)} ${cols}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
console.log('');
|
|
992
|
+
|
|
993
|
+
// Visual comparison for key metrics
|
|
994
|
+
for (const m of ['ndcg@5', 'mrr']) {
|
|
995
|
+
if (!results[0].summary[m]) continue;
|
|
996
|
+
console.log(ui.bold(` ${m.toUpperCase()}`));
|
|
997
|
+
for (const result of results) {
|
|
998
|
+
const val = result.summary[m];
|
|
999
|
+
const bar = renderBar(val, 30);
|
|
1000
|
+
const color = val === bestPerMetric[m] ? ui.green(val.toFixed(4)) : ui.cyan(val.toFixed(4));
|
|
1001
|
+
console.log(` ${result.name.padEnd(nameColW - 2)} ${bar} ${color}`);
|
|
1002
|
+
}
|
|
1003
|
+
console.log('');
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Winner summary
|
|
1007
|
+
const scores = results.map(r => ({
|
|
1008
|
+
name: r.name,
|
|
1009
|
+
score: availableMetrics.reduce((sum, m) => sum + (r.summary[m] === bestPerMetric[m] ? 1 : 0), 0),
|
|
1010
|
+
}));
|
|
1011
|
+
scores.sort((a, b) => b.score - a.score);
|
|
1012
|
+
|
|
1013
|
+
if (scores[0].score > scores[1]?.score) {
|
|
1014
|
+
console.log(ui.success(` Winner: ${scores[0].name} (best in ${scores[0].score}/${availableMetrics.length} metrics)`));
|
|
1015
|
+
} else {
|
|
1016
|
+
console.log(ui.dim(` Tie between configs - consider other factors (cost, latency)`));
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
console.log('');
|
|
1020
|
+
console.log(ui.dim(` ${testSet.length} queries × ${configs.length} configs evaluated`));
|
|
1021
|
+
console.log('');
|
|
1022
|
+
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
console.error(ui.error(err.message));
|
|
1025
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
1026
|
+
process.exit(1);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
module.exports = { registerEval, registerEvalCompare };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { loadProject } = require('../lib/project');
|
|
6
|
+
const { renderTemplate, buildContext, listTemplates, listTargets } = require('../lib/codegen');
|
|
7
|
+
const ui = require('../lib/ui');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Component to template mapping for each target.
|
|
11
|
+
*/
|
|
12
|
+
const COMPONENT_MAP = {
|
|
13
|
+
vanilla: {
|
|
14
|
+
client: 'client.js',
|
|
15
|
+
connection: 'connection.js',
|
|
16
|
+
retrieval: 'retrieval.js',
|
|
17
|
+
ingest: 'ingest.js',
|
|
18
|
+
'search-api': 'search-api.js',
|
|
19
|
+
},
|
|
20
|
+
nextjs: {
|
|
21
|
+
client: 'lib-voyage.js',
|
|
22
|
+
connection: 'lib-mongo.js',
|
|
23
|
+
retrieval: 'route-search.js', // Route includes retrieval logic
|
|
24
|
+
ingest: 'route-ingest.js',
|
|
25
|
+
'search-api': 'route-search.js',
|
|
26
|
+
'search-page': 'page-search.jsx',
|
|
27
|
+
theme: 'theme.js',
|
|
28
|
+
layout: 'layout.jsx',
|
|
29
|
+
},
|
|
30
|
+
python: {
|
|
31
|
+
client: 'voyage_client.py',
|
|
32
|
+
connection: 'mongo_client.py',
|
|
33
|
+
retrieval: 'app.py', // App includes retrieval routes
|
|
34
|
+
ingest: 'chunker.py',
|
|
35
|
+
'search-api': 'app.py',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Suggested output filenames for each component.
|
|
41
|
+
*/
|
|
42
|
+
const OUTPUT_NAMES = {
|
|
43
|
+
vanilla: {
|
|
44
|
+
client: 'lib/voyage.js',
|
|
45
|
+
connection: 'lib/mongodb.js',
|
|
46
|
+
retrieval: 'lib/retrieval.js',
|
|
47
|
+
ingest: 'lib/ingest.js',
|
|
48
|
+
'search-api': 'lib/search-api.js',
|
|
49
|
+
},
|
|
50
|
+
nextjs: {
|
|
51
|
+
client: 'lib/voyage.js',
|
|
52
|
+
connection: 'lib/mongodb.js',
|
|
53
|
+
retrieval: 'app/api/search/route.js',
|
|
54
|
+
ingest: 'app/api/ingest/route.js',
|
|
55
|
+
'search-api': 'app/api/search/route.js',
|
|
56
|
+
'search-page': 'app/search/page.jsx',
|
|
57
|
+
theme: 'lib/theme.js',
|
|
58
|
+
layout: 'app/layout.jsx',
|
|
59
|
+
},
|
|
60
|
+
python: {
|
|
61
|
+
client: 'voyage_client.py',
|
|
62
|
+
connection: 'mongo_client.py',
|
|
63
|
+
retrieval: 'app.py',
|
|
64
|
+
ingest: 'chunker.py',
|
|
65
|
+
'search-api': 'app.py',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Auto-detect target from project files.
|
|
71
|
+
*/
|
|
72
|
+
function detectTarget() {
|
|
73
|
+
const cwd = process.cwd();
|
|
74
|
+
|
|
75
|
+
// Check for Next.js
|
|
76
|
+
if (
|
|
77
|
+
fs.existsSync(path.join(cwd, 'next.config.js')) ||
|
|
78
|
+
fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
|
|
79
|
+
fs.existsSync(path.join(cwd, 'next.config.ts'))
|
|
80
|
+
) {
|
|
81
|
+
return 'nextjs';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check for Python
|
|
85
|
+
if (
|
|
86
|
+
fs.existsSync(path.join(cwd, 'requirements.txt')) ||
|
|
87
|
+
fs.existsSync(path.join(cwd, 'pyproject.toml')) ||
|
|
88
|
+
fs.existsSync(path.join(cwd, 'setup.py'))
|
|
89
|
+
) {
|
|
90
|
+
return 'python';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Default to vanilla Node.js
|
|
94
|
+
return 'vanilla';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get available components for a target.
|
|
99
|
+
*/
|
|
100
|
+
function getComponents(target) {
|
|
101
|
+
return Object.keys(COMPONENT_MAP[target] || {});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Register the generate command.
|
|
106
|
+
* @param {import('commander').Command} program
|
|
107
|
+
*/
|
|
108
|
+
function registerGenerate(program) {
|
|
109
|
+
program
|
|
110
|
+
.command('generate [component]')
|
|
111
|
+
.alias('gen')
|
|
112
|
+
.description('Generate code snippets for RAG applications')
|
|
113
|
+
.option('-t, --target <target>', 'Target framework: vanilla, nextjs, python')
|
|
114
|
+
.option('-m, --model <model>', 'Override embedding model')
|
|
115
|
+
.option('--db <database>', 'Override database name')
|
|
116
|
+
.option('--collection <name>', 'Override collection name')
|
|
117
|
+
.option('--field <name>', 'Override embedding field name')
|
|
118
|
+
.option('--index <name>', 'Override vector index name')
|
|
119
|
+
.option('-d, --dimensions <n>', 'Override dimensions', parseInt)
|
|
120
|
+
.option('--no-rerank', 'Omit reranking from generated code')
|
|
121
|
+
.option('--rerank-model <model>', 'Rerank model to use')
|
|
122
|
+
.option('--json', 'Machine-readable output with filename')
|
|
123
|
+
.option('-l, --list', 'List available components')
|
|
124
|
+
.option('-q, --quiet', 'Suppress hints and metadata')
|
|
125
|
+
.action(async (component, opts) => {
|
|
126
|
+
try {
|
|
127
|
+
// Determine target
|
|
128
|
+
const target = opts.target || detectTarget();
|
|
129
|
+
|
|
130
|
+
// Validate target
|
|
131
|
+
const validTargets = ['vanilla', 'nextjs', 'python'];
|
|
132
|
+
if (!validTargets.includes(target)) {
|
|
133
|
+
console.error(ui.error(`Invalid target: ${target}`));
|
|
134
|
+
console.error(ui.dim(` Valid targets: ${validTargets.join(', ')}`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// List components
|
|
139
|
+
if (opts.list || !component) {
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(ui.bold(`Available components for ${target}:`));
|
|
142
|
+
console.log('');
|
|
143
|
+
|
|
144
|
+
const components = getComponents(target);
|
|
145
|
+
for (const comp of components) {
|
|
146
|
+
const outputName = OUTPUT_NAMES[target][comp];
|
|
147
|
+
console.log(` ${ui.cyan(comp.padEnd(15))} → ${ui.dim(outputName)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log(ui.dim('Usage: vai generate <component> [--target <target>]'));
|
|
152
|
+
console.log(ui.dim(' vai generate retrieval > lib/retrieval.js'));
|
|
153
|
+
console.log('');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Validate component
|
|
158
|
+
const components = getComponents(target);
|
|
159
|
+
if (!components.includes(component)) {
|
|
160
|
+
console.error(ui.error(`Unknown component: ${component}`));
|
|
161
|
+
console.error(ui.dim(` Available: ${components.join(', ')}`));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Load project config
|
|
166
|
+
let project = {};
|
|
167
|
+
try {
|
|
168
|
+
project = loadProject();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// No .vai.json, use defaults
|
|
171
|
+
if (!opts.quiet) {
|
|
172
|
+
console.error(ui.warn('No .vai.json found, using defaults'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Build context with overrides
|
|
177
|
+
const context = buildContext(project, {
|
|
178
|
+
model: opts.model,
|
|
179
|
+
db: opts.db,
|
|
180
|
+
collection: opts.collection,
|
|
181
|
+
field: opts.field,
|
|
182
|
+
index: opts.index,
|
|
183
|
+
dimensions: opts.dimensions,
|
|
184
|
+
rerank: opts.rerank,
|
|
185
|
+
rerankModel: opts.rerankModel,
|
|
186
|
+
projectName: path.basename(process.cwd()),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Get template name (strip the output extension, keep for template lookup)
|
|
190
|
+
const templateFile = COMPONENT_MAP[target][component];
|
|
191
|
+
const templateName = templateFile.replace(/\.(js|jsx|py)$/, '');
|
|
192
|
+
|
|
193
|
+
// Render template
|
|
194
|
+
const output = renderTemplate(target, templateName, context);
|
|
195
|
+
|
|
196
|
+
// Output
|
|
197
|
+
if (opts.json) {
|
|
198
|
+
const filename = OUTPUT_NAMES[target][component];
|
|
199
|
+
console.log(JSON.stringify({ filename, content: output }, null, 2));
|
|
200
|
+
} else {
|
|
201
|
+
// Output to stdout for piping
|
|
202
|
+
console.log(output);
|
|
203
|
+
|
|
204
|
+
// Hints on stderr
|
|
205
|
+
if (!opts.quiet) {
|
|
206
|
+
const filename = OUTPUT_NAMES[target][component];
|
|
207
|
+
console.error('');
|
|
208
|
+
console.error(ui.dim(`# Generated ${component} for ${target}`));
|
|
209
|
+
console.error(ui.dim(`# Suggested: vai generate ${component} > ${filename}`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(ui.error(err.message));
|
|
214
|
+
if (process.env.DEBUG) console.error(err.stack);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { registerGenerate, detectTarget, getComponents };
|