@arclabs561/ai-visual-test 0.5.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/.secretsignore.example +20 -0
- package/CHANGELOG.md +360 -0
- package/CONTRIBUTING.md +63 -0
- package/DEPLOYMENT.md +80 -0
- package/LICENSE +22 -0
- package/README.md +142 -0
- package/SECURITY.md +108 -0
- package/api/health.js +34 -0
- package/api/validate.js +252 -0
- package/index.d.ts +1221 -0
- package/package.json +112 -0
- package/public/index.html +149 -0
- package/src/batch-optimizer.mjs +451 -0
- package/src/bias-detector.mjs +370 -0
- package/src/bias-mitigation.mjs +233 -0
- package/src/cache.mjs +433 -0
- package/src/config.mjs +268 -0
- package/src/constants.mjs +80 -0
- package/src/context-compressor.mjs +350 -0
- package/src/convenience.mjs +617 -0
- package/src/cost-tracker.mjs +257 -0
- package/src/cross-modal-consistency.mjs +170 -0
- package/src/data-extractor.mjs +232 -0
- package/src/dynamic-few-shot.mjs +140 -0
- package/src/dynamic-prompts.mjs +361 -0
- package/src/ensemble/index.mjs +53 -0
- package/src/ensemble-judge.mjs +366 -0
- package/src/error-handler.mjs +67 -0
- package/src/errors.mjs +167 -0
- package/src/experience-propagation.mjs +128 -0
- package/src/experience-tracer.mjs +487 -0
- package/src/explanation-manager.mjs +299 -0
- package/src/feedback-aggregator.mjs +248 -0
- package/src/game-goal-prompts.mjs +478 -0
- package/src/game-player.mjs +548 -0
- package/src/hallucination-detector.mjs +155 -0
- package/src/helpers/playwright.mjs +80 -0
- package/src/human-validation-manager.mjs +516 -0
- package/src/index.mjs +364 -0
- package/src/judge.mjs +929 -0
- package/src/latency-aware-batch-optimizer.mjs +192 -0
- package/src/load-env.mjs +159 -0
- package/src/logger.mjs +55 -0
- package/src/metrics.mjs +187 -0
- package/src/model-tier-selector.mjs +221 -0
- package/src/multi-modal/index.mjs +36 -0
- package/src/multi-modal-fusion.mjs +190 -0
- package/src/multi-modal.mjs +524 -0
- package/src/natural-language-specs.mjs +1071 -0
- package/src/pair-comparison.mjs +277 -0
- package/src/persona/index.mjs +42 -0
- package/src/persona-enhanced.mjs +200 -0
- package/src/persona-experience.mjs +572 -0
- package/src/position-counterbalance.mjs +140 -0
- package/src/prompt-composer.mjs +375 -0
- package/src/render-change-detector.mjs +583 -0
- package/src/research-enhanced-validation.mjs +436 -0
- package/src/retry.mjs +152 -0
- package/src/rubrics.mjs +231 -0
- package/src/score-tracker.mjs +277 -0
- package/src/smart-validator.mjs +447 -0
- package/src/spec-config.mjs +106 -0
- package/src/spec-templates.mjs +347 -0
- package/src/specs/index.mjs +38 -0
- package/src/temporal/index.mjs +102 -0
- package/src/temporal-adaptive.mjs +163 -0
- package/src/temporal-batch-optimizer.mjs +222 -0
- package/src/temporal-constants.mjs +69 -0
- package/src/temporal-context.mjs +49 -0
- package/src/temporal-decision-manager.mjs +271 -0
- package/src/temporal-decision.mjs +669 -0
- package/src/temporal-errors.mjs +58 -0
- package/src/temporal-note-pruner.mjs +173 -0
- package/src/temporal-preprocessor.mjs +543 -0
- package/src/temporal-prompt-formatter.mjs +219 -0
- package/src/temporal-validation.mjs +159 -0
- package/src/temporal.mjs +415 -0
- package/src/type-guards.mjs +311 -0
- package/src/uncertainty-reducer.mjs +470 -0
- package/src/utils/index.mjs +175 -0
- package/src/validation-framework.mjs +321 -0
- package/src/validation-result-normalizer.mjs +64 -0
- package/src/validation.mjs +243 -0
- package/src/validators/accessibility-programmatic.mjs +345 -0
- package/src/validators/accessibility-validator.mjs +223 -0
- package/src/validators/batch-validator.mjs +143 -0
- package/src/validators/hybrid-validator.mjs +268 -0
- package/src/validators/index.mjs +34 -0
- package/src/validators/prompt-builder.mjs +218 -0
- package/src/validators/rubric.mjs +85 -0
- package/src/validators/state-programmatic.mjs +260 -0
- package/src/validators/state-validator.mjs +291 -0
- package/vercel.json +27 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Framework
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive validation of:
|
|
5
|
+
* 1. Temporal perception accuracy (human time scales)
|
|
6
|
+
* 2. VLLM judgment accuracy (against human ground truth)
|
|
7
|
+
* 3. Gameplay temporal experience correctness
|
|
8
|
+
* 4. Webpage evaluation correctness
|
|
9
|
+
*
|
|
10
|
+
* This framework validates that our systems produce correct results,
|
|
11
|
+
* not just that they work.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { humanPerceptionTime } from './temporal-decision.mjs';
|
|
15
|
+
import { TIME_SCALES } from './temporal-constants.mjs';
|
|
16
|
+
import { aggregateTemporalNotes, calculateCoherenceExported as calculateCoherence } from './temporal.mjs';
|
|
17
|
+
import {
|
|
18
|
+
compareJudgments,
|
|
19
|
+
collectHumanJudgment,
|
|
20
|
+
loadHumanJudgment
|
|
21
|
+
} from '../evaluation/human-validation/human-validation.mjs';
|
|
22
|
+
import { getHumanValidationManager } from './human-validation-manager.mjs';
|
|
23
|
+
import { log, warn } from './logger.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate temporal perception against research values
|
|
27
|
+
*
|
|
28
|
+
* @param {Object} options - Validation options
|
|
29
|
+
* @returns {Object} Validation results
|
|
30
|
+
*/
|
|
31
|
+
export function validateTemporalPerception(options = {}) {
|
|
32
|
+
const results = {
|
|
33
|
+
researchAlignment: {},
|
|
34
|
+
consistency: {},
|
|
35
|
+
recommendations: []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Validate visual appeal time (50ms research base)
|
|
39
|
+
const visualAppealTime = humanPerceptionTime('visual-appeal', {
|
|
40
|
+
attentionLevel: 'focused',
|
|
41
|
+
actionComplexity: 'simple'
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
results.researchAlignment.visualAppeal = {
|
|
45
|
+
researchValue: 50, // Lindgaard research
|
|
46
|
+
actualValue: visualAppealTime,
|
|
47
|
+
aligned: visualAppealTime >= 50 && visualAppealTime <= 200,
|
|
48
|
+
note: visualAppealTime >= 100 ? 'Enforces 100ms minimum (implementation constraint)' : 'Matches research (50ms)'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Validate instant threshold (100ms research)
|
|
52
|
+
results.researchAlignment.instantThreshold = {
|
|
53
|
+
researchValue: 100, // NN/g research
|
|
54
|
+
actualValue: TIME_SCALES.INSTANT,
|
|
55
|
+
aligned: TIME_SCALES.INSTANT === 100,
|
|
56
|
+
note: TIME_SCALES.INSTANT === 100 ? 'Matches research' : 'Does not match research'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Validate reading time scales with content
|
|
60
|
+
const shortReading = humanPerceptionTime('reading', { contentLength: 100 });
|
|
61
|
+
const longReading = humanPerceptionTime('reading', { contentLength: 1000 });
|
|
62
|
+
|
|
63
|
+
results.researchAlignment.readingTime = {
|
|
64
|
+
scalesWithContent: longReading > shortReading,
|
|
65
|
+
shortContent: shortReading,
|
|
66
|
+
longContent: longReading,
|
|
67
|
+
note: longReading > shortReading ? 'Reading time scales with content' : 'Reading time does not scale correctly'
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Generate recommendations
|
|
71
|
+
if (!results.researchAlignment.visualAppeal.aligned) {
|
|
72
|
+
results.recommendations.push('Visual appeal time does not align with research (50ms)');
|
|
73
|
+
}
|
|
74
|
+
if (!results.researchAlignment.instantThreshold.aligned) {
|
|
75
|
+
results.recommendations.push('Instant threshold does not match research (100ms)');
|
|
76
|
+
}
|
|
77
|
+
if (!results.researchAlignment.readingTime.scalesWithContent) {
|
|
78
|
+
results.recommendations.push('Reading time does not scale correctly with content length');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate VLLM judgment accuracy against human ground truth
|
|
86
|
+
*
|
|
87
|
+
* @param {Array} humanJudgments - Human judgment ground truth
|
|
88
|
+
* @param {Array} vllmJudgments - VLLM judgments to validate
|
|
89
|
+
* @param {Object} options - Validation options
|
|
90
|
+
* @returns {Object} Validation results
|
|
91
|
+
*/
|
|
92
|
+
export function validateVLLMAccuracy(humanJudgments, vllmJudgments, options = {}) {
|
|
93
|
+
const {
|
|
94
|
+
minCorrelation = 0.7,
|
|
95
|
+
maxMAE = 1.0,
|
|
96
|
+
minKappa = 0.6
|
|
97
|
+
} = options;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const calibration = compareJudgments(humanJudgments, vllmJudgments);
|
|
101
|
+
|
|
102
|
+
const results = {
|
|
103
|
+
calibration,
|
|
104
|
+
isValid: false,
|
|
105
|
+
issues: [],
|
|
106
|
+
recommendations: []
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Check correlation
|
|
110
|
+
if (calibration.agreement.pearson < minCorrelation) {
|
|
111
|
+
results.issues.push(`Low correlation (${calibration.agreement.pearson.toFixed(3)} < ${minCorrelation})`);
|
|
112
|
+
results.isValid = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check MAE
|
|
116
|
+
if (calibration.agreement.mae > maxMAE) {
|
|
117
|
+
results.issues.push(`High MAE (${calibration.agreement.mae.toFixed(2)} > ${maxMAE})`);
|
|
118
|
+
results.isValid = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check Kappa
|
|
122
|
+
if (calibration.agreement.kappa < minKappa) {
|
|
123
|
+
results.issues.push(`Low Kappa (${calibration.agreement.kappa.toFixed(3)} < ${minKappa})`);
|
|
124
|
+
results.isValid = false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// If all checks pass
|
|
128
|
+
if (results.issues.length === 0) {
|
|
129
|
+
results.isValid = true;
|
|
130
|
+
results.recommendations.push('VLLM judgments align well with human ground truth');
|
|
131
|
+
} else {
|
|
132
|
+
results.recommendations.push(...calibration.recommendations);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return {
|
|
138
|
+
error: error.message,
|
|
139
|
+
isValid: false,
|
|
140
|
+
issues: ['Failed to compare judgments'],
|
|
141
|
+
recommendations: ['Ensure human and VLLM judgments are properly matched']
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate gameplay temporal experience
|
|
148
|
+
*
|
|
149
|
+
* @param {Array} gameplayNotes - Temporal notes from gameplay
|
|
150
|
+
* @param {Object} options - Validation options
|
|
151
|
+
* @returns {Object} Validation results
|
|
152
|
+
*/
|
|
153
|
+
export function validateGameplayTemporal(gameplayNotes, options = {}) {
|
|
154
|
+
const {
|
|
155
|
+
minCoherenceForSmooth = 0.7,
|
|
156
|
+
maxCoherenceForErratic = 0.5
|
|
157
|
+
} = options;
|
|
158
|
+
|
|
159
|
+
if (!gameplayNotes || gameplayNotes.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
isValid: false,
|
|
162
|
+
issues: ['No gameplay notes provided'],
|
|
163
|
+
recommendations: ['Provide gameplay notes for validation']
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const aggregated = aggregateTemporalNotes(gameplayNotes, options);
|
|
168
|
+
|
|
169
|
+
const results = {
|
|
170
|
+
aggregated,
|
|
171
|
+
isValid: true,
|
|
172
|
+
issues: [],
|
|
173
|
+
recommendations: []
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Check coherence
|
|
177
|
+
if (aggregated.coherence < minCoherenceForSmooth && aggregated.coherence > maxCoherenceForErratic) {
|
|
178
|
+
results.issues.push(`Moderate coherence (${aggregated.coherence.toFixed(3)}) - neither smooth nor clearly erratic`);
|
|
179
|
+
results.recommendations.push('Review gameplay notes for consistency issues');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check for conflicts
|
|
183
|
+
if (aggregated.conflicts && aggregated.conflicts.length > 0) {
|
|
184
|
+
results.issues.push(`Detected ${aggregated.conflicts.length} conflicts in gameplay notes`);
|
|
185
|
+
results.recommendations.push('Review conflicting observations in gameplay notes');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check window count
|
|
189
|
+
if (aggregated.windows.length < 2) {
|
|
190
|
+
results.issues.push('Insufficient windows for temporal analysis (need at least 2)');
|
|
191
|
+
results.recommendations.push('Collect more gameplay notes or use smaller window size');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Validate webpage evaluation correctness
|
|
199
|
+
*
|
|
200
|
+
* Validates that VLLM judgments about webpages align with human expectations.
|
|
201
|
+
*
|
|
202
|
+
* @param {Array} evaluations - Array of evaluation results
|
|
203
|
+
* @param {Object} groundTruth - Ground truth data (if available)
|
|
204
|
+
* @param {Object} options - Validation options
|
|
205
|
+
* @returns {Object} Validation results
|
|
206
|
+
*/
|
|
207
|
+
export function validateWebpageEvaluation(evaluations, groundTruth = null, options = {}) {
|
|
208
|
+
const results = {
|
|
209
|
+
evaluations,
|
|
210
|
+
isValid: true,
|
|
211
|
+
issues: [],
|
|
212
|
+
recommendations: []
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// If ground truth available, compare
|
|
216
|
+
if (groundTruth) {
|
|
217
|
+
const humanJudgments = groundTruth.humanJudgments || [];
|
|
218
|
+
const vllmJudgments = evaluations.map(eval => ({
|
|
219
|
+
id: eval.id || `eval-${Date.now()}`,
|
|
220
|
+
vllmScore: eval.score,
|
|
221
|
+
vllmIssues: eval.issues || [],
|
|
222
|
+
vllmReasoning: eval.reasoning || '',
|
|
223
|
+
provider: eval.provider || 'unknown',
|
|
224
|
+
timestamp: eval.timestamp || new Date().toISOString()
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
if (humanJudgments.length > 0 && vllmJudgments.length > 0) {
|
|
228
|
+
const accuracy = validateVLLMAccuracy(humanJudgments, vllmJudgments, options);
|
|
229
|
+
results.accuracy = accuracy;
|
|
230
|
+
results.isValid = accuracy.isValid;
|
|
231
|
+
results.issues.push(...accuracy.issues);
|
|
232
|
+
results.recommendations.push(...accuracy.recommendations);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Validate evaluation structure
|
|
237
|
+
for (const eval of evaluations) {
|
|
238
|
+
if (eval.score === null || eval.score === undefined) {
|
|
239
|
+
results.issues.push(`Evaluation ${eval.id || 'unknown'} has null/undefined score`);
|
|
240
|
+
results.isValid = false;
|
|
241
|
+
}
|
|
242
|
+
if (eval.score !== null && (eval.score < 0 || eval.score > 10)) {
|
|
243
|
+
results.issues.push(`Evaluation ${eval.id || 'unknown'} has invalid score: ${eval.score}`);
|
|
244
|
+
results.isValid = false;
|
|
245
|
+
}
|
|
246
|
+
if (!Array.isArray(eval.issues)) {
|
|
247
|
+
results.issues.push(`Evaluation ${eval.id || 'unknown'} has non-array issues`);
|
|
248
|
+
results.isValid = false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return results;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Comprehensive validation report
|
|
257
|
+
*
|
|
258
|
+
* Validates all aspects: temporal perception, VLLM accuracy, gameplay, webpage evaluation
|
|
259
|
+
*
|
|
260
|
+
* @param {Object} data - Validation data
|
|
261
|
+
* @param {Object} options - Validation options
|
|
262
|
+
* @returns {Object} Comprehensive validation report
|
|
263
|
+
*/
|
|
264
|
+
export function validateComprehensive(data, options = {}) {
|
|
265
|
+
const report = {
|
|
266
|
+
temporalPerception: null,
|
|
267
|
+
vllmAccuracy: null,
|
|
268
|
+
gameplayTemporal: null,
|
|
269
|
+
webpageEvaluation: null,
|
|
270
|
+
overall: {
|
|
271
|
+
isValid: true,
|
|
272
|
+
issues: [],
|
|
273
|
+
recommendations: []
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Validate temporal perception
|
|
278
|
+
if (data.temporalPerception !== false) {
|
|
279
|
+
report.temporalPerception = validateTemporalPerception(options);
|
|
280
|
+
if (report.temporalPerception.recommendations.length > 0) {
|
|
281
|
+
report.overall.issues.push('Temporal perception validation issues');
|
|
282
|
+
report.overall.recommendations.push(...report.temporalPerception.recommendations);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Validate VLLM accuracy
|
|
287
|
+
if (data.humanJudgments && data.vllmJudgments) {
|
|
288
|
+
report.vllmAccuracy = validateVLLMAccuracy(data.humanJudgments, data.vllmJudgments, options);
|
|
289
|
+
if (!report.vllmAccuracy.isValid) {
|
|
290
|
+
report.overall.isValid = false;
|
|
291
|
+
report.overall.issues.push('VLLM accuracy validation failed');
|
|
292
|
+
report.overall.recommendations.push(...report.vllmAccuracy.recommendations);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Validate gameplay temporal
|
|
297
|
+
if (data.gameplayNotes) {
|
|
298
|
+
report.gameplayTemporal = validateGameplayTemporal(data.gameplayNotes, options);
|
|
299
|
+
if (report.gameplayTemporal.issues.length > 0) {
|
|
300
|
+
report.overall.issues.push('Gameplay temporal validation issues');
|
|
301
|
+
report.overall.recommendations.push(...report.gameplayTemporal.recommendations);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Validate webpage evaluation
|
|
306
|
+
if (data.evaluations) {
|
|
307
|
+
report.webpageEvaluation = validateWebpageEvaluation(
|
|
308
|
+
data.evaluations,
|
|
309
|
+
data.groundTruth,
|
|
310
|
+
options
|
|
311
|
+
);
|
|
312
|
+
if (!report.webpageEvaluation.isValid) {
|
|
313
|
+
report.overall.isValid = false;
|
|
314
|
+
report.overall.issues.push('Webpage evaluation validation failed');
|
|
315
|
+
report.overall.recommendations.push(...report.webpageEvaluation.recommendations);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return report;
|
|
320
|
+
}
|
|
321
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation Result Normalizer
|
|
3
|
+
*
|
|
4
|
+
* Ensures validation results have consistent structure.
|
|
5
|
+
* Centralizes normalization logic to avoid duplication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { warn } from './logger.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalize validation result to ensure consistent structure
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} result - Validation result from validateScreenshot
|
|
14
|
+
* @param {string} [source] - Source function name for logging
|
|
15
|
+
* @returns {Object} Normalized validation result
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeValidationResult(result, source = 'unknown') {
|
|
18
|
+
if (!result) {
|
|
19
|
+
warn(`[Normalizer] ${source}: result is null/undefined`);
|
|
20
|
+
return {
|
|
21
|
+
enabled: false,
|
|
22
|
+
score: null,
|
|
23
|
+
issues: [],
|
|
24
|
+
reasoning: 'Result was null or undefined',
|
|
25
|
+
assessment: null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Create a copy to avoid mutating the original
|
|
30
|
+
const normalized = { ...result };
|
|
31
|
+
|
|
32
|
+
// Ensure enabled field is always present (default to true if not specified)
|
|
33
|
+
if (normalized.enabled === undefined) {
|
|
34
|
+
// If enabled is not specified, infer from presence of other fields
|
|
35
|
+
normalized.enabled = normalized.score !== null || normalized.judgment || normalized.provider !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Ensure score is always present (may be null)
|
|
39
|
+
if (normalized.score === null || normalized.score === undefined) {
|
|
40
|
+
if (normalized.score === undefined) {
|
|
41
|
+
warn(`[Normalizer] ${source}: score is undefined, defaulting to null`);
|
|
42
|
+
}
|
|
43
|
+
normalized.score = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Ensure issues is always an array
|
|
47
|
+
if (!Array.isArray(normalized.issues)) {
|
|
48
|
+
warn(`[Normalizer] ${source}: issues is not an array, defaulting to empty array`);
|
|
49
|
+
normalized.issues = [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ensure reasoning is always present
|
|
53
|
+
if (!normalized.reasoning) {
|
|
54
|
+
normalized.reasoning = normalized.judgment || normalized.message || 'No reasoning provided';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Ensure assessment is present (may be null)
|
|
58
|
+
if (normalized.assessment === undefined) {
|
|
59
|
+
normalized.assessment = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Validation Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides validation functions for common input types to prevent
|
|
5
|
+
* security vulnerabilities and improve error messages.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ValidationError } from './errors.mjs';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import { normalize, resolve } from 'path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate image file path
|
|
14
|
+
*
|
|
15
|
+
* @param {string} imagePath - Path to image file
|
|
16
|
+
* @returns {true} Always returns true if valid
|
|
17
|
+
* @throws {ValidationError} If path is invalid, empty, or contains path traversal
|
|
18
|
+
*/
|
|
19
|
+
export function validateImagePath(imagePath) {
|
|
20
|
+
if (typeof imagePath !== 'string') {
|
|
21
|
+
throw new ValidationError('Image path must be a string', null, {
|
|
22
|
+
received: typeof imagePath
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (imagePath.length === 0) {
|
|
27
|
+
throw new ValidationError('Image path cannot be empty');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check for path traversal attempts
|
|
31
|
+
const normalized = normalize(imagePath);
|
|
32
|
+
if (normalized.includes('..')) {
|
|
33
|
+
throw new ValidationError('Invalid image path: path traversal detected', imagePath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validate file extension
|
|
37
|
+
const validExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp'];
|
|
38
|
+
const hasValidExtension = validExtensions.some(ext =>
|
|
39
|
+
imagePath.toLowerCase().endsWith(ext)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (!hasValidExtension) {
|
|
43
|
+
throw new ValidationError('Invalid image format. Supported: png, jpg, jpeg, gif, webp', imagePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if file exists (optional - may not exist in all contexts)
|
|
47
|
+
// This is a soft check - don't throw if file doesn't exist yet
|
|
48
|
+
// The actual file operations will handle missing files
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate prompt string
|
|
55
|
+
*
|
|
56
|
+
* @param {string} prompt - Prompt text to validate
|
|
57
|
+
* @param {number} [maxLength=10000] - Maximum allowed length
|
|
58
|
+
* @returns {true} Always returns true if valid
|
|
59
|
+
* @throws {ValidationError} If prompt is invalid, empty, or too long
|
|
60
|
+
*/
|
|
61
|
+
export function validatePrompt(prompt, maxLength = 10000) {
|
|
62
|
+
if (typeof prompt !== 'string') {
|
|
63
|
+
throw new ValidationError('Prompt must be a string', null, {
|
|
64
|
+
received: typeof prompt
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (prompt.length === 0) {
|
|
69
|
+
throw new ValidationError('Prompt cannot be empty');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (prompt.length > maxLength) {
|
|
73
|
+
throw new ValidationError(`Prompt too long (max ${maxLength} characters)`, null, {
|
|
74
|
+
length: prompt.length,
|
|
75
|
+
maxLength
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate context object
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} context - Context object to validate (can be null/undefined)
|
|
86
|
+
* @param {number} [maxSize=50000] - Maximum serialized size in bytes
|
|
87
|
+
* @returns {true} Always returns true if valid
|
|
88
|
+
* @throws {ValidationError} If context is invalid type, too large, or non-serializable
|
|
89
|
+
*/
|
|
90
|
+
export function validateContext(context, maxSize = 50000) {
|
|
91
|
+
if (context === null || context === undefined) {
|
|
92
|
+
return true; // Context is optional
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof context !== 'object' || Array.isArray(context)) {
|
|
96
|
+
throw new ValidationError('Context must be an object', null, {
|
|
97
|
+
received: Array.isArray(context) ? 'array' : typeof context
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check size by stringifying
|
|
102
|
+
try {
|
|
103
|
+
const contextString = JSON.stringify(context);
|
|
104
|
+
if (contextString.length > maxSize) {
|
|
105
|
+
throw new ValidationError(`Context too large (max ${maxSize} bytes)`, null, {
|
|
106
|
+
size: contextString.length,
|
|
107
|
+
maxSize
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof ValidationError) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
throw new ValidationError('Context contains non-serializable data', null, {
|
|
115
|
+
originalError: error.message
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate timeout value
|
|
124
|
+
*
|
|
125
|
+
* @param {number} timeout - Timeout value in milliseconds
|
|
126
|
+
* @param {number} [min=1000] - Minimum allowed timeout
|
|
127
|
+
* @param {number} [max=300000] - Maximum allowed timeout
|
|
128
|
+
* @returns {true} Always returns true if valid
|
|
129
|
+
* @throws {ValidationError} If timeout is invalid, too short, or too long
|
|
130
|
+
*/
|
|
131
|
+
export function validateTimeout(timeout, min = 1000, max = 300000) {
|
|
132
|
+
if (typeof timeout !== 'number') {
|
|
133
|
+
throw new ValidationError('Timeout must be a number', null, {
|
|
134
|
+
received: typeof timeout
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (timeout < min) {
|
|
139
|
+
throw new ValidationError(`Timeout too short (min ${min}ms)`, null, {
|
|
140
|
+
timeout,
|
|
141
|
+
min
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (timeout > max) {
|
|
146
|
+
throw new ValidationError(`Timeout too long (max ${max}ms)`, null, {
|
|
147
|
+
timeout,
|
|
148
|
+
max
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Validate schema object for data extraction
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} schema - Schema object to validate
|
|
159
|
+
* @returns {true} Always returns true if valid
|
|
160
|
+
* @throws {ValidationError} If schema is invalid or has invalid field types
|
|
161
|
+
*/
|
|
162
|
+
export function validateSchema(schema) {
|
|
163
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
164
|
+
throw new ValidationError('Schema must be a non-empty object', null, {
|
|
165
|
+
received: Array.isArray(schema) ? 'array' : typeof schema
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const validTypes = ['string', 'number', 'boolean', 'object', 'array'];
|
|
170
|
+
|
|
171
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
172
|
+
if (typeof field !== 'object' || Array.isArray(field)) {
|
|
173
|
+
throw new ValidationError(`Schema field "${key}" must be an object`, null, {
|
|
174
|
+
field
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!field.type || !validTypes.includes(field.type)) {
|
|
179
|
+
throw new ValidationError(`Schema field "${key}" has invalid type`, null, {
|
|
180
|
+
type: field.type,
|
|
181
|
+
validTypes
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validate file path (general purpose)
|
|
191
|
+
*
|
|
192
|
+
* @param {string} filePath - File path to validate
|
|
193
|
+
* @param {{
|
|
194
|
+
* mustExist?: boolean;
|
|
195
|
+
* allowedExtensions?: string[] | null;
|
|
196
|
+
* maxLength?: number;
|
|
197
|
+
* }} [options={}] - Validation options
|
|
198
|
+
* @returns {true} Always returns true if valid
|
|
199
|
+
* @throws {ValidationError} If path is invalid, empty, too long, has path traversal, wrong extension, or doesn't exist (if required)
|
|
200
|
+
*/
|
|
201
|
+
export function validateFilePath(filePath, options = {}) {
|
|
202
|
+
const {
|
|
203
|
+
mustExist = false,
|
|
204
|
+
allowedExtensions = null,
|
|
205
|
+
maxLength = 4096
|
|
206
|
+
} = options;
|
|
207
|
+
|
|
208
|
+
if (typeof filePath !== 'string') {
|
|
209
|
+
throw new ValidationError('File path must be a string', null, {
|
|
210
|
+
received: typeof filePath
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (filePath.length === 0) {
|
|
215
|
+
throw new ValidationError('File path cannot be empty');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (filePath.length > maxLength) {
|
|
219
|
+
throw new ValidationError(`File path too long (max ${maxLength} characters)`, filePath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Check for path traversal
|
|
223
|
+
const normalized = normalize(filePath);
|
|
224
|
+
if (normalized.includes('..')) {
|
|
225
|
+
throw new ValidationError('Invalid file path: path traversal detected', filePath);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check extension if specified
|
|
229
|
+
if (allowedExtensions) {
|
|
230
|
+
const ext = normalized.substring(normalized.lastIndexOf('.'));
|
|
231
|
+
if (!allowedExtensions.includes(ext.toLowerCase())) {
|
|
232
|
+
throw new ValidationError(`Invalid file extension. Allowed: ${allowedExtensions.join(', ')}`, filePath);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check existence if required
|
|
237
|
+
if (mustExist && !existsSync(filePath)) {
|
|
238
|
+
throw new ValidationError('File does not exist', filePath);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|