@iservu-inc/adf-cli 0.9.1 → 0.11.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/.claude/settings.local.json +18 -0
- package/.project/PROJECT-SETTINGS.md +68 -0
- package/.project/chats/current/2025-10-05_UX-IMPROVEMENTS-AND-AI-ANALYSIS-CONFIG.md +389 -0
- package/.project/chats/current/SESSION-STATUS.md +205 -228
- package/.project/docs/DOCUMENTATION-UPDATE-CHECKLIST.md +196 -0
- package/.project/docs/ROADMAP.md +142 -44
- package/.project/docs/designs/LEARNING-ANALYTICS-DASHBOARD.md +1383 -0
- package/.project/docs/designs/PATTERN-DECAY-ALGORITHM.md +526 -0
- package/CHANGELOG.md +683 -0
- package/README.md +119 -24
- package/lib/learning/analytics-exporter.js +241 -0
- package/lib/learning/analytics-view.js +508 -0
- package/lib/learning/analytics.js +681 -0
- package/lib/learning/decay-manager.js +336 -0
- package/lib/learning/learning-manager.js +19 -6
- package/lib/learning/pattern-detector.js +285 -2
- package/lib/learning/storage.js +49 -1
- package/lib/utils/pre-publish-check.js +74 -0
- package/package.json +3 -2
- package/scripts/generate-test-data.js +557 -0
- package/tests/analytics-exporter.test.js +477 -0
- package/tests/analytics-view.test.js +466 -0
- package/tests/analytics.test.js +712 -0
- package/tests/decay-manager.test.js +394 -0
- package/tests/pattern-decay.test.js +339 -0
- /package/.project/chats/{current → complete}/2025-10-05_INTELLIGENT-ANSWER-ANALYSIS.md +0 -0
- /package/.project/chats/{current → complete}/2025-10-05_MULTI-IDE-IMPROVEMENTS.md +0 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const analytics = require('../lib/learning/analytics');
|
|
4
|
+
const storage = require('../lib/learning/storage');
|
|
5
|
+
|
|
6
|
+
describe('Learning Analytics', () => {
|
|
7
|
+
const tempDir = path.join(__dirname, 'temp-analytics-test');
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await fs.ensureDir(tempDir);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await fs.remove(tempDir);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('generateAnalytics', () => {
|
|
18
|
+
test('should generate analytics with no data', async () => {
|
|
19
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
20
|
+
|
|
21
|
+
expect(result.version).toBe('1.0');
|
|
22
|
+
expect(result.exportedAt).toBeTruthy();
|
|
23
|
+
expect(result.overview.totalSessions).toBe(0);
|
|
24
|
+
expect(result.overview.totalSkips).toBe(0);
|
|
25
|
+
expect(result.overview.totalAnswers).toBe(0);
|
|
26
|
+
// skipTrends always returns 12 weeks (with 0 values when no data)
|
|
27
|
+
expect(result.skipTrends.length).toBe(12);
|
|
28
|
+
expect(result.skipTrends[0].totalSkips).toBe(0);
|
|
29
|
+
expect(result.categoryPreferences).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('should generate complete analytics with real data', async () => {
|
|
33
|
+
// Set up test data
|
|
34
|
+
const skipHistory = {
|
|
35
|
+
sessions: [
|
|
36
|
+
{
|
|
37
|
+
sessionId: 's1',
|
|
38
|
+
timestamp: new Date('2025-01-01').toISOString(),
|
|
39
|
+
skips: [
|
|
40
|
+
{ questionId: 'q1', text: 'Deploy?', category: 'deployment', reason: 'manual' },
|
|
41
|
+
{ questionId: 'q2', text: 'CI/CD?', category: 'deployment', reason: 'manual' }
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
sessionId: 's2',
|
|
46
|
+
timestamp: new Date('2025-01-08').toISOString(),
|
|
47
|
+
skips: [
|
|
48
|
+
{ questionId: 'q1', text: 'Deploy?', category: 'deployment', reason: 'filtered' }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const answerHistory = {
|
|
55
|
+
sessions: [
|
|
56
|
+
{
|
|
57
|
+
sessionId: 's1',
|
|
58
|
+
timestamp: new Date('2025-01-01').toISOString(),
|
|
59
|
+
answers: [
|
|
60
|
+
{ questionId: 'q3', text: 'Testing?', category: 'testing', answer: 'Jest', wordCount: 10 }
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const patterns = {
|
|
67
|
+
patterns: [
|
|
68
|
+
{
|
|
69
|
+
id: 'p1',
|
|
70
|
+
questionId: 'q1',
|
|
71
|
+
confidence: 95,
|
|
72
|
+
timesApplied: 10,
|
|
73
|
+
createdAt: new Date('2025-01-01').toISOString(),
|
|
74
|
+
lastSeen: new Date('2025-01-08').toISOString()
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'p2',
|
|
78
|
+
questionId: 'q2',
|
|
79
|
+
confidence: 60,
|
|
80
|
+
timesApplied: 2,
|
|
81
|
+
createdAt: new Date('2024-06-01').toISOString(),
|
|
82
|
+
lastSeen: new Date('2024-06-15').toISOString()
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const rules = {
|
|
88
|
+
rules: [
|
|
89
|
+
{ id: 'r1', enabled: true, timesApplied: 15 },
|
|
90
|
+
{ id: 'r2', enabled: true, timesApplied: 8 }
|
|
91
|
+
]
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
95
|
+
await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
|
|
96
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
97
|
+
await storage.writeLearningData(tempDir, 'learned-rules.json', rules);
|
|
98
|
+
await storage.updateLearningStats(tempDir, {
|
|
99
|
+
totalSessions: 2,
|
|
100
|
+
totalSkips: 3,
|
|
101
|
+
totalAnswers: 1
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
105
|
+
|
|
106
|
+
expect(result.overview.totalSessions).toBe(2);
|
|
107
|
+
expect(result.overview.totalSkips).toBe(3);
|
|
108
|
+
expect(result.overview.manualSkips).toBe(2);
|
|
109
|
+
expect(result.overview.filteredSkips).toBe(1);
|
|
110
|
+
expect(result.overview.totalAnswers).toBe(1);
|
|
111
|
+
expect(result.overview.activePatterns).toBe(2);
|
|
112
|
+
expect(result.overview.activeRules).toBe(2);
|
|
113
|
+
expect(result.skipTrends.length).toBeGreaterThan(0);
|
|
114
|
+
expect(result.categoryPreferences.length).toBeGreaterThan(0);
|
|
115
|
+
expect(result.patternDistribution.totalPatterns).toBe(2);
|
|
116
|
+
expect(result.decayStatus).toBeTruthy();
|
|
117
|
+
expect(result.effectiveness).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('should handle missing data files gracefully', async () => {
|
|
121
|
+
// Only create skip history, leave others missing
|
|
122
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', {
|
|
123
|
+
sessions: [
|
|
124
|
+
{
|
|
125
|
+
sessionId: 's1',
|
|
126
|
+
timestamp: new Date().toISOString(),
|
|
127
|
+
skips: [{ questionId: 'q1', reason: 'manual' }]
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
133
|
+
|
|
134
|
+
expect(result.overview.totalSkips).toBeGreaterThan(0);
|
|
135
|
+
expect(result.overview.totalAnswers).toBe(0);
|
|
136
|
+
expect(result.overview.activePatterns).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('Overview Statistics', () => {
|
|
141
|
+
test('should calculate learning age correctly', async () => {
|
|
142
|
+
const skipHistory = {
|
|
143
|
+
sessions: [
|
|
144
|
+
{
|
|
145
|
+
sessionId: 's1',
|
|
146
|
+
timestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
|
147
|
+
skips: []
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
153
|
+
|
|
154
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
155
|
+
|
|
156
|
+
expect(result.overview.learningAge).toBe(30);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('should separate manual and filtered skips', async () => {
|
|
160
|
+
const skipHistory = {
|
|
161
|
+
sessions: [
|
|
162
|
+
{
|
|
163
|
+
sessionId: 's1',
|
|
164
|
+
skips: [
|
|
165
|
+
{ questionId: 'q1', reason: 'manual' },
|
|
166
|
+
{ questionId: 'q2', reason: 'manual' },
|
|
167
|
+
{ questionId: 'q3', reason: 'filtered' },
|
|
168
|
+
{ questionId: 'q4', reason: 'filtered' },
|
|
169
|
+
{ questionId: 'q5', reason: 'filtered' }
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
176
|
+
|
|
177
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
178
|
+
|
|
179
|
+
expect(result.overview.totalSkips).toBe(5);
|
|
180
|
+
expect(result.overview.manualSkips).toBe(2);
|
|
181
|
+
expect(result.overview.filteredSkips).toBe(3);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('Skip Trends', () => {
|
|
186
|
+
test('should group sessions by week', async () => {
|
|
187
|
+
const now = new Date();
|
|
188
|
+
const skipHistory = {
|
|
189
|
+
sessions: [
|
|
190
|
+
{
|
|
191
|
+
sessionId: 's1',
|
|
192
|
+
timestamp: new Date(now - 0 * 24 * 60 * 60 * 1000).toISOString(), // This week
|
|
193
|
+
skips: [{ questionId: 'q1', reason: 'manual' }]
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
sessionId: 's2',
|
|
197
|
+
timestamp: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(), // Last week
|
|
198
|
+
skips: [{ questionId: 'q2', reason: 'manual' }]
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
sessionId: 's3',
|
|
202
|
+
timestamp: new Date(now - 14 * 24 * 60 * 60 * 1000).toISOString(), // 2 weeks ago
|
|
203
|
+
skips: [{ questionId: 'q3', reason: 'filtered' }]
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
209
|
+
|
|
210
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
211
|
+
|
|
212
|
+
expect(result.skipTrends.length).toBe(12);
|
|
213
|
+
// Check that skips are distributed across weeks (may vary based on week boundaries)
|
|
214
|
+
const totalSkipsAcrossWeeks = result.skipTrends.reduce((sum, week) => sum + week.totalSkips, 0);
|
|
215
|
+
expect(totalSkipsAcrossWeeks).toBeGreaterThanOrEqual(2); // At least 2 skips captured
|
|
216
|
+
expect(totalSkipsAcrossWeeks).toBeLessThanOrEqual(3); // At most 3 skips
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('should calculate skip rate correctly', async () => {
|
|
220
|
+
const skipHistory = {
|
|
221
|
+
sessions: [
|
|
222
|
+
{
|
|
223
|
+
sessionId: 's1',
|
|
224
|
+
timestamp: new Date().toISOString(),
|
|
225
|
+
skips: [{ questionId: 'q1', reason: 'manual' }]
|
|
226
|
+
}
|
|
227
|
+
]
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const answerHistory = {
|
|
231
|
+
sessions: [
|
|
232
|
+
{
|
|
233
|
+
sessionId: 's1',
|
|
234
|
+
timestamp: new Date().toISOString(),
|
|
235
|
+
answers: [
|
|
236
|
+
{ questionId: 'q2' },
|
|
237
|
+
{ questionId: 'q3' },
|
|
238
|
+
{ questionId: 'q4' }
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
]
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
245
|
+
await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
|
|
246
|
+
|
|
247
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
248
|
+
|
|
249
|
+
// Skip rate is calculated from sessions with both skips and answers
|
|
250
|
+
// Since skipHistory sessions don't have answers, skipRate will be 100% (skips only)
|
|
251
|
+
const currentWeek = result.skipTrends[result.skipTrends.length - 1];
|
|
252
|
+
expect(currentWeek.skipRate).toBeGreaterThanOrEqual(0);
|
|
253
|
+
expect(currentWeek.skipRate).toBeLessThanOrEqual(100);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('should limit to last 12 weeks', async () => {
|
|
257
|
+
const sessions = [];
|
|
258
|
+
for (let i = 0; i < 20; i++) {
|
|
259
|
+
sessions.push({
|
|
260
|
+
sessionId: `s${i}`,
|
|
261
|
+
timestamp: new Date(Date.now() - i * 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
262
|
+
skips: [{ questionId: `q${i}`, reason: 'manual' }]
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', { sessions });
|
|
267
|
+
|
|
268
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
269
|
+
|
|
270
|
+
expect(result.skipTrends.length).toBeLessThanOrEqual(12);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('Category Preferences', () => {
|
|
275
|
+
test('should aggregate skips and answers by category', async () => {
|
|
276
|
+
const skipHistory = {
|
|
277
|
+
sessions: [
|
|
278
|
+
{
|
|
279
|
+
sessionId: 's1',
|
|
280
|
+
skips: [
|
|
281
|
+
{ questionId: 'q1', category: 'deployment', reason: 'manual' },
|
|
282
|
+
{ questionId: 'q2', category: 'deployment', reason: 'manual' }
|
|
283
|
+
]
|
|
284
|
+
}
|
|
285
|
+
]
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const answerHistory = {
|
|
289
|
+
sessions: [
|
|
290
|
+
{
|
|
291
|
+
sessionId: 's1',
|
|
292
|
+
answers: [
|
|
293
|
+
{ questionId: 'q3', category: 'deployment' },
|
|
294
|
+
{ questionId: 'q4', category: 'testing' }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
301
|
+
await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
|
|
302
|
+
|
|
303
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
304
|
+
|
|
305
|
+
const deployment = result.categoryPreferences.find(c => c.category === 'deployment');
|
|
306
|
+
const testing = result.categoryPreferences.find(c => c.category === 'testing');
|
|
307
|
+
|
|
308
|
+
expect(deployment.skips).toBe(2);
|
|
309
|
+
expect(deployment.answers).toBe(1);
|
|
310
|
+
expect(deployment.total).toBe(3);
|
|
311
|
+
expect(deployment.skipRate).toBeCloseTo(67, 0); // 2/3 = 66.666... rounds to 67
|
|
312
|
+
|
|
313
|
+
expect(testing.skips).toBe(0);
|
|
314
|
+
expect(testing.answers).toBe(1);
|
|
315
|
+
expect(testing.skipRate).toBe(0);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('should categorize preference levels correctly', async () => {
|
|
319
|
+
const skipHistory = {
|
|
320
|
+
sessions: [
|
|
321
|
+
{
|
|
322
|
+
sessionId: 's1',
|
|
323
|
+
skips: [
|
|
324
|
+
// High skip rate (80%)
|
|
325
|
+
{ questionId: 'q1', category: 'cat1', reason: 'manual' },
|
|
326
|
+
{ questionId: 'q2', category: 'cat1', reason: 'manual' },
|
|
327
|
+
{ questionId: 'q3', category: 'cat1', reason: 'manual' },
|
|
328
|
+
{ questionId: 'q4', category: 'cat1', reason: 'manual' },
|
|
329
|
+
// Low skip rate (20%)
|
|
330
|
+
{ questionId: 'q5', category: 'cat2', reason: 'manual' }
|
|
331
|
+
]
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const answerHistory = {
|
|
337
|
+
sessions: [
|
|
338
|
+
{
|
|
339
|
+
sessionId: 's1',
|
|
340
|
+
answers: [
|
|
341
|
+
{ questionId: 'q6', category: 'cat1' }, // 4 skips, 1 answer = 80%
|
|
342
|
+
{ questionId: 'q7', category: 'cat2' },
|
|
343
|
+
{ questionId: 'q8', category: 'cat2' },
|
|
344
|
+
{ questionId: 'q9', category: 'cat2' },
|
|
345
|
+
{ questionId: 'q10', category: 'cat2' } // 1 skip, 4 answers = 20%
|
|
346
|
+
]
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
352
|
+
await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
|
|
353
|
+
|
|
354
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
355
|
+
|
|
356
|
+
const cat1 = result.categoryPreferences.find(c => c.category === 'cat1');
|
|
357
|
+
const cat2 = result.categoryPreferences.find(c => c.category === 'cat2');
|
|
358
|
+
|
|
359
|
+
expect(cat1.level).toBe('high');
|
|
360
|
+
expect(cat2.level).toBe('low');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('should sort categories by skip rate descending', async () => {
|
|
364
|
+
const skipHistory = {
|
|
365
|
+
sessions: [
|
|
366
|
+
{
|
|
367
|
+
sessionId: 's1',
|
|
368
|
+
skips: [
|
|
369
|
+
{ questionId: 'q1', category: 'low', reason: 'manual' },
|
|
370
|
+
{ questionId: 'q2', category: 'medium', reason: 'manual' },
|
|
371
|
+
{ questionId: 'q3', category: 'medium', reason: 'manual' },
|
|
372
|
+
{ questionId: 'q4', category: 'high', reason: 'manual' },
|
|
373
|
+
{ questionId: 'q5', category: 'high', reason: 'manual' },
|
|
374
|
+
{ questionId: 'q6', category: 'high', reason: 'manual' }
|
|
375
|
+
]
|
|
376
|
+
}
|
|
377
|
+
]
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const answerHistory = {
|
|
381
|
+
sessions: [
|
|
382
|
+
{
|
|
383
|
+
sessionId: 's1',
|
|
384
|
+
answers: [
|
|
385
|
+
{ questionId: 'q7', category: 'low' },
|
|
386
|
+
{ questionId: 'q8', category: 'low' },
|
|
387
|
+
{ questionId: 'q9', category: 'low' }, // 1 skip, 3 answers = 25%
|
|
388
|
+
{ questionId: 'q10', category: 'medium' }, // 2 skips, 1 answer = 66%
|
|
389
|
+
{ questionId: 'q11', category: 'high' } // 3 skips, 1 answer = 75%
|
|
390
|
+
]
|
|
391
|
+
}
|
|
392
|
+
]
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
396
|
+
await storage.writeLearningData(tempDir, 'answer-history.json', answerHistory);
|
|
397
|
+
|
|
398
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
399
|
+
|
|
400
|
+
expect(result.categoryPreferences[0].category).toBe('high');
|
|
401
|
+
expect(result.categoryPreferences[1].category).toBe('medium');
|
|
402
|
+
expect(result.categoryPreferences[2].category).toBe('low');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('Pattern Distribution', () => {
|
|
407
|
+
test('should categorize patterns by confidence level', async () => {
|
|
408
|
+
const patterns = {
|
|
409
|
+
patterns: [
|
|
410
|
+
{ id: 'p1', confidence: 95, timesApplied: 10 }, // High (90%+)
|
|
411
|
+
{ id: 'p2', confidence: 92, timesApplied: 5 }, // High
|
|
412
|
+
{ id: 'p3', confidence: 85, timesApplied: 3 }, // Medium (75-89%)
|
|
413
|
+
{ id: 'p4', confidence: 80, timesApplied: 2 }, // Medium
|
|
414
|
+
{ id: 'p5', confidence: 65, timesApplied: 1 }, // Low (50-74%)
|
|
415
|
+
{ id: 'p6', confidence: 45, timesApplied: 1 } // Very Low (<50%)
|
|
416
|
+
]
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
420
|
+
|
|
421
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
422
|
+
|
|
423
|
+
expect(result.patternDistribution.totalPatterns).toBe(6);
|
|
424
|
+
expect(result.patternDistribution.distribution.high).toBe(2);
|
|
425
|
+
expect(result.patternDistribution.distribution.medium).toBe(2);
|
|
426
|
+
expect(result.patternDistribution.distribution.low).toBe(1);
|
|
427
|
+
expect(result.patternDistribution.distribution.veryLow).toBe(1);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('should calculate average confidence correctly', async () => {
|
|
431
|
+
const patterns = {
|
|
432
|
+
patterns: [
|
|
433
|
+
{ id: 'p1', confidence: 100, timesApplied: 1 },
|
|
434
|
+
{ id: 'p2', confidence: 80, timesApplied: 1 },
|
|
435
|
+
{ id: 'p3', confidence: 60, timesApplied: 1 }
|
|
436
|
+
]
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
440
|
+
|
|
441
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
442
|
+
|
|
443
|
+
expect(result.patternDistribution.avgConfidence).toBe(80); // (100 + 80 + 60) / 3
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('should count patterns at risk (confidence < 50)', async () => {
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const patterns = {
|
|
449
|
+
patterns: [
|
|
450
|
+
{ id: 'p1', confidence: 48, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() }, // 10 days ago
|
|
451
|
+
{ id: 'p2', confidence: 42, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() }, // 10 days ago
|
|
452
|
+
{ id: 'p3', confidence: 90, timesApplied: 1, lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString() } // 10 days ago
|
|
453
|
+
]
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
457
|
+
|
|
458
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
459
|
+
|
|
460
|
+
expect(result.patternDistribution.atRiskCount).toBe(2); // Only p1 and p2 (< 50 confidence)
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('Effectiveness Metrics', () => {
|
|
465
|
+
test('should calculate time saved based on filtered skips', async () => {
|
|
466
|
+
const skipHistory = {
|
|
467
|
+
sessions: [
|
|
468
|
+
{
|
|
469
|
+
sessionId: 's1',
|
|
470
|
+
skips: [
|
|
471
|
+
{ questionId: 'q1', reason: 'filtered' },
|
|
472
|
+
{ questionId: 'q2', reason: 'filtered' },
|
|
473
|
+
{ questionId: 'q3', reason: 'filtered' }
|
|
474
|
+
]
|
|
475
|
+
}
|
|
476
|
+
]
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
480
|
+
|
|
481
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
482
|
+
|
|
483
|
+
// 3 filtered skips * 2.5 minutes average = 7.5, rounds to 8 minutes
|
|
484
|
+
expect(result.effectiveness.timeSavedMinutes).toBe(8);
|
|
485
|
+
expect(result.effectiveness.timeSavedHours).toBe("0.1"); // 8/60 = 0.133, formatted to 1 decimal = "0.1"
|
|
486
|
+
expect(result.effectiveness.questionsFiltered).toBe(3);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('should calculate pattern accuracy', async () => {
|
|
490
|
+
const patterns = {
|
|
491
|
+
patterns: [
|
|
492
|
+
{ id: 'p1', confidence: 95, timesApplied: 10, userApproved: true },
|
|
493
|
+
{ id: 'p2', confidence: 85, timesApplied: 5, userApproved: true },
|
|
494
|
+
{ id: 'p3', confidence: 70, timesApplied: 3, userApproved: false }
|
|
495
|
+
]
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
499
|
+
|
|
500
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
501
|
+
|
|
502
|
+
// Pattern accuracy = (user-approved patterns / total patterns) * 100
|
|
503
|
+
// 2 approved / 3 total = 66.67%, rounds to 67%
|
|
504
|
+
expect(result.effectiveness.patternAccuracy).toBe(67);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('should calculate rule application statistics', async () => {
|
|
508
|
+
const rules = {
|
|
509
|
+
rules: [
|
|
510
|
+
{ id: 'r1', enabled: true, appliedCount: 15 },
|
|
511
|
+
{ id: 'r2', enabled: true, appliedCount: 10 },
|
|
512
|
+
{ id: 'r3', enabled: true, appliedCount: 5 },
|
|
513
|
+
{ id: 'r4', enabled: false, appliedCount: 100 } // Disabled, but appliedCount is still counted
|
|
514
|
+
]
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
await storage.writeLearningData(tempDir, 'learned-rules.json', rules);
|
|
518
|
+
|
|
519
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
520
|
+
|
|
521
|
+
expect(result.effectiveness.totalRuleApplications).toBe(130); // All rules counted (15+10+5+100)
|
|
522
|
+
expect(result.effectiveness.avgApplicationsPerRule).toBe(33); // 130 / 4 rules = 32.5, rounds to 33
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('should calculate overall effectiveness score', async () => {
|
|
526
|
+
const skipHistory = {
|
|
527
|
+
sessions: [
|
|
528
|
+
{
|
|
529
|
+
sessionId: 's1',
|
|
530
|
+
skips: [
|
|
531
|
+
{ questionId: 'q1', reason: 'filtered' },
|
|
532
|
+
{ questionId: 'q2', reason: 'manual' }
|
|
533
|
+
]
|
|
534
|
+
}
|
|
535
|
+
]
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const patterns = {
|
|
539
|
+
patterns: [
|
|
540
|
+
{ id: 'p1', confidence: 90, timesApplied: 10, userApproved: true }
|
|
541
|
+
]
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
545
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
546
|
+
|
|
547
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
548
|
+
|
|
549
|
+
expect(result.effectiveness.overallEffectiveness).toBe(100); // 1/1 patterns approved = 100%
|
|
550
|
+
expect(result.effectiveness.overallEffectiveness).toBeLessThanOrEqual(100);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe('Decay Status', () => {
|
|
555
|
+
test('should categorize pattern health correctly', async () => {
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
const patterns = {
|
|
558
|
+
patterns: [
|
|
559
|
+
// Healthy: high confidence, recent
|
|
560
|
+
{
|
|
561
|
+
id: 'p1',
|
|
562
|
+
confidence: 90,
|
|
563
|
+
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
564
|
+
lastSeen: new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString()
|
|
565
|
+
},
|
|
566
|
+
// Warning: medium confidence or older
|
|
567
|
+
{
|
|
568
|
+
id: 'p2',
|
|
569
|
+
confidence: 65,
|
|
570
|
+
createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
571
|
+
lastSeen: new Date(now - 35 * 24 * 60 * 60 * 1000).toISOString()
|
|
572
|
+
},
|
|
573
|
+
// Critical: low confidence, old
|
|
574
|
+
{
|
|
575
|
+
id: 'p3',
|
|
576
|
+
confidence: 45,
|
|
577
|
+
createdAt: new Date(now - 120 * 24 * 60 * 60 * 1000).toISOString(),
|
|
578
|
+
lastSeen: new Date(now - 100 * 24 * 60 * 60 * 1000).toISOString()
|
|
579
|
+
}
|
|
580
|
+
]
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
584
|
+
|
|
585
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
586
|
+
|
|
587
|
+
expect(result.decayStatus.healthy).toBe(1);
|
|
588
|
+
expect(result.decayStatus.warning).toBe(1);
|
|
589
|
+
expect(result.decayStatus.critical).toBe(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('should count recently renewed patterns', async () => {
|
|
593
|
+
const now = Date.now();
|
|
594
|
+
const patterns = {
|
|
595
|
+
patterns: [
|
|
596
|
+
{
|
|
597
|
+
id: 'p1',
|
|
598
|
+
confidence: 90,
|
|
599
|
+
createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
600
|
+
lastSeen: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), // Recent
|
|
601
|
+
timesRenewed: 2
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
id: 'p2',
|
|
605
|
+
confidence: 85,
|
|
606
|
+
createdAt: new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
607
|
+
lastSeen: new Date(now - 40 * 24 * 60 * 60 * 1000).toISOString(), // Not recent
|
|
608
|
+
timesRenewed: 1
|
|
609
|
+
}
|
|
610
|
+
]
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
614
|
+
|
|
615
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
616
|
+
|
|
617
|
+
expect(result.decayStatus.recentlyRenewed).toBeGreaterThan(0);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test('should calculate average pattern age', async () => {
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
const patterns = {
|
|
623
|
+
patterns: [
|
|
624
|
+
{
|
|
625
|
+
id: 'p1',
|
|
626
|
+
confidence: 90,
|
|
627
|
+
createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days old
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: 'p2',
|
|
631
|
+
confidence: 85,
|
|
632
|
+
createdAt: new Date(now - 60 * 24 * 60 * 60 * 1000).toISOString() // 60 days old
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
638
|
+
|
|
639
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
640
|
+
|
|
641
|
+
expect(result.decayStatus.avgAge).toBe(45); // (30 + 60) / 2
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
describe('Edge Cases', () => {
|
|
646
|
+
test('should handle empty pattern array', async () => {
|
|
647
|
+
await storage.writeLearningData(tempDir, 'patterns.json', { patterns: [] });
|
|
648
|
+
|
|
649
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
650
|
+
|
|
651
|
+
expect(result.patternDistribution.totalPatterns).toBe(0);
|
|
652
|
+
expect(result.patternDistribution.avgConfidence).toBe(0);
|
|
653
|
+
expect(result.decayStatus.avgAge).toBe(0);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('should handle sessions without timestamps', async () => {
|
|
657
|
+
const skipHistory = {
|
|
658
|
+
sessions: [
|
|
659
|
+
{
|
|
660
|
+
sessionId: 's1',
|
|
661
|
+
// No timestamp
|
|
662
|
+
skips: [{ questionId: 'q1', reason: 'manual' }]
|
|
663
|
+
}
|
|
664
|
+
]
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', skipHistory);
|
|
668
|
+
|
|
669
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
670
|
+
|
|
671
|
+
expect(result.overview.totalSkips).toBe(1);
|
|
672
|
+
expect(result.skipTrends.length).toBeGreaterThanOrEqual(0);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test('should handle patterns without metadata', async () => {
|
|
676
|
+
const patterns = {
|
|
677
|
+
patterns: [
|
|
678
|
+
{
|
|
679
|
+
id: 'p1',
|
|
680
|
+
confidence: 90
|
|
681
|
+
// No createdAt, lastSeen, timesApplied
|
|
682
|
+
}
|
|
683
|
+
]
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
await storage.writeLearningData(tempDir, 'patterns.json', patterns);
|
|
687
|
+
|
|
688
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
689
|
+
|
|
690
|
+
expect(result.patternDistribution.totalPatterns).toBe(1);
|
|
691
|
+
expect(result.effectiveness.patternAccuracy).toBeGreaterThanOrEqual(0);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('should handle very large datasets', async () => {
|
|
695
|
+
const sessions = [];
|
|
696
|
+
for (let i = 0; i < 1000; i++) {
|
|
697
|
+
sessions.push({
|
|
698
|
+
sessionId: `s${i}`,
|
|
699
|
+
timestamp: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
|
|
700
|
+
skips: [{ questionId: `q${i}`, reason: 'manual' }]
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
await storage.writeLearningData(tempDir, 'skip-history.json', { sessions });
|
|
705
|
+
|
|
706
|
+
const result = await analytics.generateAnalytics(tempDir);
|
|
707
|
+
|
|
708
|
+
expect(result.overview.totalSessions).toBe(1000);
|
|
709
|
+
expect(result.skipTrends.length).toBeLessThanOrEqual(12); // Should still cap at 12 weeks
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
});
|