@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.
Files changed (93) hide show
  1. package/.secretsignore.example +20 -0
  2. package/CHANGELOG.md +360 -0
  3. package/CONTRIBUTING.md +63 -0
  4. package/DEPLOYMENT.md +80 -0
  5. package/LICENSE +22 -0
  6. package/README.md +142 -0
  7. package/SECURITY.md +108 -0
  8. package/api/health.js +34 -0
  9. package/api/validate.js +252 -0
  10. package/index.d.ts +1221 -0
  11. package/package.json +112 -0
  12. package/public/index.html +149 -0
  13. package/src/batch-optimizer.mjs +451 -0
  14. package/src/bias-detector.mjs +370 -0
  15. package/src/bias-mitigation.mjs +233 -0
  16. package/src/cache.mjs +433 -0
  17. package/src/config.mjs +268 -0
  18. package/src/constants.mjs +80 -0
  19. package/src/context-compressor.mjs +350 -0
  20. package/src/convenience.mjs +617 -0
  21. package/src/cost-tracker.mjs +257 -0
  22. package/src/cross-modal-consistency.mjs +170 -0
  23. package/src/data-extractor.mjs +232 -0
  24. package/src/dynamic-few-shot.mjs +140 -0
  25. package/src/dynamic-prompts.mjs +361 -0
  26. package/src/ensemble/index.mjs +53 -0
  27. package/src/ensemble-judge.mjs +366 -0
  28. package/src/error-handler.mjs +67 -0
  29. package/src/errors.mjs +167 -0
  30. package/src/experience-propagation.mjs +128 -0
  31. package/src/experience-tracer.mjs +487 -0
  32. package/src/explanation-manager.mjs +299 -0
  33. package/src/feedback-aggregator.mjs +248 -0
  34. package/src/game-goal-prompts.mjs +478 -0
  35. package/src/game-player.mjs +548 -0
  36. package/src/hallucination-detector.mjs +155 -0
  37. package/src/helpers/playwright.mjs +80 -0
  38. package/src/human-validation-manager.mjs +516 -0
  39. package/src/index.mjs +364 -0
  40. package/src/judge.mjs +929 -0
  41. package/src/latency-aware-batch-optimizer.mjs +192 -0
  42. package/src/load-env.mjs +159 -0
  43. package/src/logger.mjs +55 -0
  44. package/src/metrics.mjs +187 -0
  45. package/src/model-tier-selector.mjs +221 -0
  46. package/src/multi-modal/index.mjs +36 -0
  47. package/src/multi-modal-fusion.mjs +190 -0
  48. package/src/multi-modal.mjs +524 -0
  49. package/src/natural-language-specs.mjs +1071 -0
  50. package/src/pair-comparison.mjs +277 -0
  51. package/src/persona/index.mjs +42 -0
  52. package/src/persona-enhanced.mjs +200 -0
  53. package/src/persona-experience.mjs +572 -0
  54. package/src/position-counterbalance.mjs +140 -0
  55. package/src/prompt-composer.mjs +375 -0
  56. package/src/render-change-detector.mjs +583 -0
  57. package/src/research-enhanced-validation.mjs +436 -0
  58. package/src/retry.mjs +152 -0
  59. package/src/rubrics.mjs +231 -0
  60. package/src/score-tracker.mjs +277 -0
  61. package/src/smart-validator.mjs +447 -0
  62. package/src/spec-config.mjs +106 -0
  63. package/src/spec-templates.mjs +347 -0
  64. package/src/specs/index.mjs +38 -0
  65. package/src/temporal/index.mjs +102 -0
  66. package/src/temporal-adaptive.mjs +163 -0
  67. package/src/temporal-batch-optimizer.mjs +222 -0
  68. package/src/temporal-constants.mjs +69 -0
  69. package/src/temporal-context.mjs +49 -0
  70. package/src/temporal-decision-manager.mjs +271 -0
  71. package/src/temporal-decision.mjs +669 -0
  72. package/src/temporal-errors.mjs +58 -0
  73. package/src/temporal-note-pruner.mjs +173 -0
  74. package/src/temporal-preprocessor.mjs +543 -0
  75. package/src/temporal-prompt-formatter.mjs +219 -0
  76. package/src/temporal-validation.mjs +159 -0
  77. package/src/temporal.mjs +415 -0
  78. package/src/type-guards.mjs +311 -0
  79. package/src/uncertainty-reducer.mjs +470 -0
  80. package/src/utils/index.mjs +175 -0
  81. package/src/validation-framework.mjs +321 -0
  82. package/src/validation-result-normalizer.mjs +64 -0
  83. package/src/validation.mjs +243 -0
  84. package/src/validators/accessibility-programmatic.mjs +345 -0
  85. package/src/validators/accessibility-validator.mjs +223 -0
  86. package/src/validators/batch-validator.mjs +143 -0
  87. package/src/validators/hybrid-validator.mjs +268 -0
  88. package/src/validators/index.mjs +34 -0
  89. package/src/validators/prompt-builder.mjs +218 -0
  90. package/src/validators/rubric.mjs +85 -0
  91. package/src/validators/state-programmatic.mjs +260 -0
  92. package/src/validators/state-validator.mjs +291 -0
  93. package/vercel.json +27 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Pair Comparison Evaluation
3
+ *
4
+ * Implements pairwise comparison evaluation method.
5
+ * Research shows Pair Comparison is more reliable than absolute scoring
6
+ * (MLLM-as-a-Judge, arXiv:2402.04788).
7
+ *
8
+ * Instead of scoring each screenshot independently, compares pairs
9
+ * to determine which is better, then derives relative scores.
10
+ */
11
+
12
+ import { VLLMJudge } from './judge.mjs';
13
+ import { detectBias, detectPositionBias } from './bias-detector.mjs';
14
+ import { composeComparisonPrompt } from './prompt-composer.mjs';
15
+
16
+ /**
17
+ * Compare two screenshots and determine which is better
18
+ *
19
+ * @param {string} imagePath1 - Path to first screenshot
20
+ * @param {string} imagePath2 - Path to second screenshot
21
+ * @param {string} prompt - Evaluation prompt describing what to compare
22
+ * @param {import('./index.mjs').ValidationContext} [context={}] - Validation context
23
+ * @returns {Promise<import('./index.mjs').PairComparisonResult>} Comparison result
24
+ */
25
+ export async function comparePair(imagePath1, imagePath2, prompt, context = {}) {
26
+ const judge = new VLLMJudge(context);
27
+
28
+ if (!judge.enabled) {
29
+ return {
30
+ enabled: false,
31
+ winner: null,
32
+ confidence: null,
33
+ reasoning: 'VLLM validation is disabled',
34
+ comparison: null
35
+ };
36
+ }
37
+
38
+ const comparisonPrompt = buildComparisonPrompt(prompt, context);
39
+
40
+ // Randomize order to reduce position bias
41
+ const [first, second, order] = Math.random() > 0.5
42
+ ? [imagePath1, imagePath2, 'original']
43
+ : [imagePath2, imagePath1, 'reversed'];
44
+
45
+ const fullPrompt = `${comparisonPrompt}
46
+
47
+ You are comparing two screenshots. Screenshot A is shown first, then Screenshot B.
48
+
49
+ SCREENSHOT A:
50
+ [First screenshot will be provided]
51
+
52
+ SCREENSHOT B:
53
+ [Second screenshot will be provided]
54
+
55
+ Compare them and determine which is better based on the evaluation criteria. Return JSON:
56
+ {
57
+ "winner": "A" | "B" | "tie",
58
+ "confidence": 0.0-1.0,
59
+ "reasoning": "explanation of comparison",
60
+ "differences": ["key difference 1", "key difference 2"],
61
+ "scores": {
62
+ "A": 0-10,
63
+ "B": 0-10
64
+ }
65
+ }`;
66
+
67
+ try {
68
+ // TRUE MULTI-IMAGE COMPARISON: Send both images in single API call
69
+ // This is the research-optimal approach (MLLM-as-a-Judge, arXiv:2402.04788)
70
+ const comparisonResult = await judge.judgeScreenshot([first, second], comparisonPrompt, {
71
+ ...context,
72
+ comparisonContext: { position: 'both', total: 2 }
73
+ });
74
+
75
+ if (!comparisonResult.enabled || comparisonResult.error) {
76
+ return {
77
+ enabled: false,
78
+ winner: null,
79
+ confidence: null,
80
+ reasoning: comparisonResult.error || 'Comparison failed',
81
+ comparison: null,
82
+ error: comparisonResult.error || 'API disabled'
83
+ };
84
+ }
85
+
86
+ // Parse comparison result - expect JSON with winner, scores, reasoning
87
+ const judgment = comparisonResult.judgment || '';
88
+ let parsedComparison = null;
89
+
90
+ try {
91
+ const jsonMatch = judgment.match(/\{[\s\S]*\}/);
92
+ if (jsonMatch) {
93
+ parsedComparison = JSON.parse(jsonMatch[0]);
94
+ }
95
+ } catch (e) {
96
+ // Fall through to score-based comparison
97
+ }
98
+
99
+ // If we got structured comparison, use it
100
+ if (parsedComparison && parsedComparison.winner) {
101
+ const winner = parsedComparison.winner.toLowerCase();
102
+ const scoreA = parsedComparison.scores?.A ?? parsedComparison.scores?.['A'] ?? null;
103
+ const scoreB = parsedComparison.scores?.B ?? parsedComparison.scores?.['B'] ?? null;
104
+
105
+ // Map winner back to original order
106
+ const mappedWinner = order === 'reversed'
107
+ ? (winner === 'a' ? 'B' : winner === 'b' ? 'A' : 'tie')
108
+ : (winner === 'a' ? 'A' : winner === 'b' ? 'B' : 'tie');
109
+
110
+ return {
111
+ enabled: true,
112
+ winner: mappedWinner,
113
+ confidence: parsedComparison.confidence ?? 0.5,
114
+ reasoning: parsedComparison.reasoning || comparisonResult.reasoning || 'Direct comparison completed',
115
+ differences: parsedComparison.differences || [],
116
+ comparison: {
117
+ score1: scoreA ?? (mappedWinner === 'A' ? 8 : mappedWinner === 'B' ? 6 : 7),
118
+ score2: scoreB ?? (mappedWinner === 'B' ? 8 : mappedWinner === 'A' ? 6 : 7),
119
+ difference: scoreA && scoreB ? Math.abs(scoreA - scoreB) : null,
120
+ order: order === 'reversed' ? 'reversed' : 'original',
121
+ method: 'multi-image'
122
+ },
123
+ biasDetection: {
124
+ positionBias: false, // Multi-image eliminates position bias
125
+ adjusted: false
126
+ },
127
+ metadata: {
128
+ provider: comparisonResult.provider,
129
+ cached: comparisonResult.cached || false,
130
+ responseTime: comparisonResult.responseTime || 0,
131
+ estimatedCost: comparisonResult.estimatedCost,
132
+ logprobs: comparisonResult.logprobs || null // Include if available
133
+ }
134
+ };
135
+ }
136
+
137
+ // Fallback: If structured parse failed, treat as tie (multi-image should return structured JSON)
138
+ // This should rarely happen if prompt is clear
139
+ return {
140
+ enabled: true,
141
+ winner: 'tie',
142
+ confidence: 0.5,
143
+ reasoning: comparisonResult.reasoning || 'Comparison completed but could not parse structured result. Treating as tie.',
144
+ comparison: {
145
+ score1: comparisonResult.score ?? 7,
146
+ score2: comparisonResult.score ?? 7,
147
+ difference: 0,
148
+ order: order === 'reversed' ? 'reversed' : 'original',
149
+ method: 'multi-image-fallback'
150
+ },
151
+ biasDetection: {
152
+ positionBias: false,
153
+ adjusted: false
154
+ },
155
+ metadata: {
156
+ provider: comparisonResult.provider,
157
+ cached: comparisonResult.cached || false,
158
+ responseTime: comparisonResult.responseTime || 0,
159
+ estimatedCost: comparisonResult.estimatedCost,
160
+ logprobs: comparisonResult.logprobs || null,
161
+ warning: 'Structured comparison parse failed - using fallback'
162
+ }
163
+ };
164
+ } catch (error) {
165
+ return {
166
+ enabled: false,
167
+ winner: null,
168
+ confidence: null,
169
+ reasoning: `Comparison failed: ${error.message}`,
170
+ comparison: null,
171
+ error: error.message
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Build comparison prompt from base prompt
178
+ *
179
+ * Now uses unified prompt composition system for research-backed consistency.
180
+ */
181
+ function buildComparisonPrompt(basePrompt, context) {
182
+ try {
183
+ return composeComparisonPrompt(basePrompt, context, {
184
+ includeRubric: context.includeRubric !== false // Default true (research-backed)
185
+ });
186
+ } catch (error) {
187
+ // Fallback to basic comparison prompt
188
+ return `Compare the two screenshots based on the following criteria:
189
+
190
+ ${basePrompt}
191
+
192
+ Focus on:
193
+ - Which screenshot better meets the criteria?
194
+ - What are the key differences?
195
+ - Which has fewer issues?
196
+ - Which provides better user experience?
197
+
198
+ Be specific about what makes one better than the other.`;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Rank multiple screenshots using pairwise comparisons
204
+ * Uses tournament-style ranking
205
+ *
206
+ * @param {Array<string>} imagePaths - Array of screenshot paths
207
+ * @param {string} prompt - Evaluation prompt
208
+ * @param {import('./index.mjs').ValidationContext} [context={}] - Validation context
209
+ * @returns {Promise<import('./index.mjs').BatchRankingResult>} Ranking result
210
+ */
211
+ export async function rankBatch(imagePaths, prompt, context = {}) {
212
+ if (imagePaths.length < 2) {
213
+ return {
214
+ enabled: false,
215
+ rankings: [],
216
+ error: 'Need at least 2 screenshots for ranking'
217
+ };
218
+ }
219
+
220
+ // For efficiency, compare each pair
221
+ // In practice, you might use a tournament bracket or sampling
222
+ const comparisons = [];
223
+ const scores = new Map();
224
+
225
+ // Compare all pairs
226
+ for (let i = 0; i < imagePaths.length; i++) {
227
+ for (let j = i + 1; j < imagePaths.length; j++) {
228
+ const comparison = await comparePair(
229
+ imagePaths[i],
230
+ imagePaths[j],
231
+ prompt,
232
+ context
233
+ );
234
+
235
+ if (comparison.enabled && comparison.winner !== 'tie') {
236
+ comparisons.push({
237
+ image1: i,
238
+ image2: j,
239
+ winner: comparison.winner === 'A' ? i : j,
240
+ confidence: comparison.confidence
241
+ });
242
+
243
+ // Update scores based on wins
244
+ const winnerIdx = comparison.winner === 'A' ? i : j;
245
+ const loserIdx = comparison.winner === 'A' ? j : i;
246
+
247
+ scores.set(winnerIdx, (scores.get(winnerIdx) || 0) + comparison.confidence);
248
+ scores.set(loserIdx, (scores.get(loserIdx) || 0) + (1 - comparison.confidence));
249
+ }
250
+ }
251
+ }
252
+
253
+ // Rank by scores
254
+ const rankings = Array.from(scores.entries())
255
+ .map(([idx, score]) => ({
256
+ index: idx,
257
+ path: imagePaths[idx],
258
+ score,
259
+ wins: comparisons.filter(c => c.winner === idx).length
260
+ }))
261
+ .sort((a, b) => b.score - a.score)
262
+ .map((r, rank) => ({
263
+ ...r,
264
+ rank: rank + 1
265
+ }));
266
+
267
+ return {
268
+ enabled: true,
269
+ rankings,
270
+ comparisons: comparisons.length,
271
+ metadata: {
272
+ totalScreenshots: imagePaths.length,
273
+ totalComparisons: comparisons.length
274
+ }
275
+ };
276
+ }
277
+
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Persona Sub-Module
3
+ *
4
+ * Persona-based experience testing and evaluation.
5
+ *
6
+ * Import from 'ai-visual-test/persona'
7
+ */
8
+
9
+ // Core persona experience
10
+ export {
11
+ experiencePageAsPersona,
12
+ experiencePageWithPersonas
13
+ } from '../persona-experience.mjs';
14
+
15
+ // Enhanced persona
16
+ export {
17
+ createEnhancedPersona,
18
+ experiencePageWithEnhancedPersona,
19
+ calculatePersonaConsistency,
20
+ calculatePersonaDiversity
21
+ } from '../persona-enhanced.mjs';
22
+
23
+ // Experience tracing
24
+ export {
25
+ ExperienceTrace,
26
+ ExperienceTracerManager,
27
+ getTracerManager
28
+ } from '../experience-tracer.mjs';
29
+
30
+ // Experience propagation
31
+ export {
32
+ ExperiencePropagationTracker,
33
+ getPropagationTracker,
34
+ trackPropagation
35
+ } from '../experience-propagation.mjs';
36
+
37
+ // Explanation manager
38
+ export {
39
+ ExplanationManager,
40
+ getExplanationManager
41
+ } from '../explanation-manager.mjs';
42
+
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Enhanced Persona Structure
3
+ *
4
+ * Adds rich context to personas based on research findings:
5
+ * - Workflows, frustrations, usage patterns
6
+ * - Temporal evolution tracking
7
+ * - Consistency metrics
8
+ *
9
+ * Research:
10
+ * - "Can LLM be a Personalized Judge?" - Persona-based LLM judging with uncertainty estimation
11
+ * - "The Prompt Makes the Person(a)" - Systematic evaluation of sociodemographic persona prompting
12
+ * - "Persona-judge: Personalized Alignment of Large Language Models" - Personalized alignment
13
+ * - "PERSONA: Evaluating Pluralistic Alignment in LLMs" - Pluralistic alignment with personas
14
+ *
15
+ * Note: Research shows direct persona-based judging has low reliability, but uncertainty
16
+ * estimation improves performance to >80% agreement on high-certainty samples. LLMs struggle
17
+ * to authentically simulate marginalized groups. Multi-agent debate can amplify bias.
18
+ */
19
+
20
+ import { experiencePageAsPersona } from './persona-experience.mjs';
21
+
22
+ /**
23
+ * Enhanced persona structure with rich context
24
+ *
25
+ * @typedef {Object} EnhancedPersona
26
+ * @property {string} name - Persona name
27
+ * @property {string} device - Device type
28
+ * @property {string[]} goals - Primary goals
29
+ * @property {string[]} concerns - Primary concerns
30
+ * @property {Object} workflows - Documented workflows and use cases
31
+ * @property {string[]} frustrations - Specific frustrations
32
+ * @property {Object} usagePatterns - Historical usage patterns
33
+ * @property {Object} temporalEvolution - Temporal usage evolution
34
+ */
35
+
36
+ /**
37
+ * Create enhanced persona with rich context
38
+ *
39
+ * @param {Object} basePersona - Base persona (name, device, goals, concerns)
40
+ * @param {{
41
+ * workflows?: Object;
42
+ * frustrations?: string[];
43
+ * usagePatterns?: Object;
44
+ * temporalEvolution?: Object;
45
+ * }} [context={}] - Rich context
46
+ * @returns {EnhancedPersona} Enhanced persona
47
+ */
48
+ export function createEnhancedPersona(basePersona, context = {}) {
49
+ return {
50
+ ...basePersona,
51
+ workflows: context.workflows || {
52
+ primary: [],
53
+ secondary: [],
54
+ edgeCases: []
55
+ },
56
+ frustrations: context.frustrations || [],
57
+ usagePatterns: context.usagePatterns || {
58
+ frequency: 'unknown',
59
+ duration: 'unknown',
60
+ peakTimes: []
61
+ },
62
+ temporalEvolution: context.temporalEvolution || {
63
+ firstUse: null,
64
+ lastUse: null,
65
+ usageTrend: 'stable'
66
+ }
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Calculate consistency metrics for persona observations
72
+ *
73
+ * @param {Array} observations - Array of observations from persona
74
+ * @returns {Object} Consistency metrics
75
+ */
76
+ export function calculatePersonaConsistency(observations) {
77
+ if (observations.length < 2) {
78
+ return {
79
+ promptToLine: 1.0,
80
+ lineToLine: 1.0,
81
+ overall: 1.0
82
+ };
83
+ }
84
+
85
+ // Extract keywords from each observation
86
+ const keywordSets = observations.map(obs => {
87
+ const text = typeof obs === 'string' ? obs : obs.observation || '';
88
+ const words = text.toLowerCase()
89
+ .split(/\s+/)
90
+ .filter(w => w.length > 3)
91
+ .filter(w => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can'].includes(w));
92
+ return new Set(words);
93
+ });
94
+
95
+ // Calculate prompt-to-line consistency (first vs all others)
96
+ const firstKeywords = keywordSets[0];
97
+ let promptToLineMatches = 0;
98
+ for (let i = 1; i < keywordSets.length; i++) {
99
+ const intersection = new Set([...firstKeywords].filter(x => keywordSets[i].has(x)));
100
+ const union = new Set([...firstKeywords, ...keywordSets[i]]);
101
+ const similarity = union.size > 0 ? intersection.size / union.size : 0;
102
+ promptToLineMatches += similarity;
103
+ }
104
+ const promptToLine = promptToLineMatches / Math.max(1, keywordSets.length - 1);
105
+
106
+ // Calculate line-to-line consistency (adjacent observations)
107
+ let lineToLineMatches = 0;
108
+ for (let i = 1; i < keywordSets.length; i++) {
109
+ const prev = keywordSets[i - 1];
110
+ const curr = keywordSets[i];
111
+ const intersection = new Set([...prev].filter(x => curr.has(x)));
112
+ const union = new Set([...prev, ...curr]);
113
+ const similarity = union.size > 0 ? intersection.size / union.size : 0;
114
+ lineToLineMatches += similarity;
115
+ }
116
+ const lineToLine = lineToLineMatches / Math.max(1, keywordSets.length - 1);
117
+
118
+ // Overall consistency (weighted average)
119
+ const overall = (promptToLine * 0.4 + lineToLine * 0.6);
120
+
121
+ return {
122
+ promptToLine,
123
+ lineToLine,
124
+ overall,
125
+ observationCount: observations.length
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Experience page with enhanced persona
131
+ *
132
+ * @param {any} page - Playwright page object
133
+ * @param {EnhancedPersona} persona - Enhanced persona
134
+ * @param {Object} options - Experience options
135
+ * @returns {Promise<Object>} Experience result with consistency metrics
136
+ */
137
+ export async function experiencePageWithEnhancedPersona(page, persona, options = {}) {
138
+ // Use base experience function
139
+ const experience = await experiencePageAsPersona(page, persona, options);
140
+
141
+ // Extract observations
142
+ const observations = experience.notes.map(n => n.observation || '');
143
+
144
+ // Calculate consistency metrics
145
+ const consistency = calculatePersonaConsistency(observations);
146
+
147
+ // Add persona context to experience
148
+ return {
149
+ ...experience,
150
+ persona: {
151
+ ...persona,
152
+ workflows: persona.workflows,
153
+ frustrations: persona.frustrations,
154
+ usagePatterns: persona.usagePatterns
155
+ },
156
+ consistency,
157
+ observations
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Compare persona observations for diversity
163
+ *
164
+ * @param {Array} personaExperiences - Array of persona experience results
165
+ * @returns {Object} Diversity metrics
166
+ */
167
+ export function calculatePersonaDiversity(personaExperiences) {
168
+ if (personaExperiences.length < 2) {
169
+ return {
170
+ diversityRatio: 0,
171
+ uniqueKeywords: 0,
172
+ totalKeywords: 0
173
+ };
174
+ }
175
+
176
+ // Extract all keywords from all personas
177
+ const allKeywords = personaExperiences.flatMap(exp => {
178
+ const observations = exp.observations || exp.notes?.map(n => n.observation || '') || [];
179
+ return observations.flatMap(obs => {
180
+ const words = obs.toLowerCase()
181
+ .split(/\s+/)
182
+ .filter(w => w.length > 3)
183
+ .filter(w => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can'].includes(w));
184
+ return words;
185
+ });
186
+ });
187
+
188
+ const uniqueKeywords = new Set(allKeywords);
189
+ const diversityRatio = uniqueKeywords.size / Math.max(1, allKeywords.length);
190
+
191
+ return {
192
+ diversityRatio,
193
+ uniqueKeywords: uniqueKeywords.size,
194
+ totalKeywords: allKeywords.length,
195
+ personaCount: personaExperiences.length
196
+ };
197
+ }
198
+
199
+
200
+