@operor/analytics 0.1.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/dist/index.d.ts +224 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +571 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/AnalyticsCollector.test.ts +344 -0
- package/src/AnalyticsCollector.ts +148 -0
- package/src/InMemoryAnalyticsStore.test.ts +495 -0
- package/src/InMemoryAnalyticsStore.ts +250 -0
- package/src/SQLiteAnalyticsStore.test.ts +554 -0
- package/src/SQLiteAnalyticsStore.ts +367 -0
- package/src/index.ts +4 -0
- package/src/types.ts +149 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { InMemoryAnalyticsStore } from './InMemoryAnalyticsStore.js';
|
|
3
|
+
import type { AnalyticsEvent, TimeRange } from './types.js';
|
|
4
|
+
|
|
5
|
+
function makeEvent(overrides: Partial<AnalyticsEvent> = {}): AnalyticsEvent {
|
|
6
|
+
return {
|
|
7
|
+
timestamp: Date.now(),
|
|
8
|
+
channel: 'whatsapp',
|
|
9
|
+
customerPhone: '+1234567890',
|
|
10
|
+
customerId: 'cust_1',
|
|
11
|
+
agentName: 'support',
|
|
12
|
+
intent: 'order_status',
|
|
13
|
+
intentConfidence: 0.95,
|
|
14
|
+
responseTimeMs: 450,
|
|
15
|
+
llmCost: 0.002,
|
|
16
|
+
kbTopScore: 0.85,
|
|
17
|
+
kbIsFaqMatch: true,
|
|
18
|
+
kbResultCount: 3,
|
|
19
|
+
toolCallsCount: 0,
|
|
20
|
+
toolCallsJson: null,
|
|
21
|
+
messageLength: 30,
|
|
22
|
+
responseLength: 120,
|
|
23
|
+
isNewCustomer: false,
|
|
24
|
+
wasEscalated: false,
|
|
25
|
+
conversationId: null,
|
|
26
|
+
promptTokens: 100,
|
|
27
|
+
completionTokens: 50,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('InMemoryAnalyticsStore', () => {
|
|
33
|
+
let store: InMemoryAnalyticsStore;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
store = new InMemoryAnalyticsStore();
|
|
37
|
+
await store.initialize();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// --- CRUD / recordMessageEvent ---
|
|
41
|
+
|
|
42
|
+
describe('recordMessageEvent', () => {
|
|
43
|
+
it('records a single event and reflects in summary', async () => {
|
|
44
|
+
await store.recordMessageEvent(makeEvent());
|
|
45
|
+
const summary = await store.getSummary();
|
|
46
|
+
expect(summary.totalMessages).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('records multiple events', async () => {
|
|
50
|
+
await store.recordMessageEvent(makeEvent());
|
|
51
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+999' }));
|
|
52
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'billing' }));
|
|
53
|
+
const summary = await store.getSummary();
|
|
54
|
+
expect(summary.totalMessages).toBe(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('stores event data without mutation', async () => {
|
|
58
|
+
const event = makeEvent({ intent: 'refund' });
|
|
59
|
+
await store.recordMessageEvent(event);
|
|
60
|
+
// Mutate original — store should be unaffected
|
|
61
|
+
event.intent = 'MUTATED';
|
|
62
|
+
const kb = await store.getKbStats();
|
|
63
|
+
expect(kb.totalQueries).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- Conversation sessions ---
|
|
68
|
+
|
|
69
|
+
describe('getOrCreateConversation', () => {
|
|
70
|
+
it('creates a new conversation', async () => {
|
|
71
|
+
const id = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
72
|
+
expect(id).toBeTruthy();
|
|
73
|
+
expect(typeof id).toBe('string');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns same conversation within session gap', async () => {
|
|
77
|
+
const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
78
|
+
const id2 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
79
|
+
expect(id2).toBe(id1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('creates new conversation for different customer', async () => {
|
|
83
|
+
const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
84
|
+
const id2 = await store.getOrCreateConversation('+9999999999', 'whatsapp', 'support');
|
|
85
|
+
expect(id2).not.toBe(id1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('creates new conversation for different channel', async () => {
|
|
89
|
+
const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
90
|
+
const id2 = await store.getOrCreateConversation('+1234567890', 'telegram', 'support');
|
|
91
|
+
expect(id2).not.toBe(id1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('creates new conversation for different agent', async () => {
|
|
95
|
+
const id1 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
96
|
+
const id2 = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'billing');
|
|
97
|
+
expect(id2).not.toBe(id1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('updateConversationStats', () => {
|
|
102
|
+
it('updates conversation after message event', async () => {
|
|
103
|
+
const convId = await store.getOrCreateConversation('+1234567890', 'whatsapp', 'support');
|
|
104
|
+
const event = makeEvent({ conversationId: convId, toolCallsCount: 2, llmCost: 0.01 });
|
|
105
|
+
await store.updateConversationStats(convId, event);
|
|
106
|
+
// No crash — stats updated internally
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('handles non-existent conversation gracefully', async () => {
|
|
110
|
+
const event = makeEvent();
|
|
111
|
+
// Should not throw
|
|
112
|
+
await store.updateConversationStats('non-existent-id', event);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// --- getSummary ---
|
|
117
|
+
|
|
118
|
+
describe('getSummary', () => {
|
|
119
|
+
it('returns zeros for empty store', async () => {
|
|
120
|
+
const summary = await store.getSummary();
|
|
121
|
+
expect(summary.totalMessages).toBe(0);
|
|
122
|
+
expect(summary.avgPerDay).toBe(0);
|
|
123
|
+
expect(summary.peakDay).toBeNull();
|
|
124
|
+
expect(summary.uniqueCustomers).toBe(0);
|
|
125
|
+
expect(summary.newCustomers).toBe(0);
|
|
126
|
+
expect(summary.returningCustomers).toBe(0);
|
|
127
|
+
expect(summary.kbAnsweredPct).toBe(0);
|
|
128
|
+
expect(summary.faqHitRate).toBe(0);
|
|
129
|
+
expect(summary.avgResponseTime).toBe(0);
|
|
130
|
+
expect(summary.pendingReviews).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('counts unique customers', async () => {
|
|
134
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
|
|
135
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
|
|
136
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+2' }));
|
|
137
|
+
const summary = await store.getSummary();
|
|
138
|
+
expect(summary.uniqueCustomers).toBe(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('counts new vs returning customers', async () => {
|
|
142
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1', isNewCustomer: true }));
|
|
143
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+2', isNewCustomer: false }));
|
|
144
|
+
const summary = await store.getSummary();
|
|
145
|
+
expect(summary.newCustomers).toBe(1);
|
|
146
|
+
expect(summary.returningCustomers).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('calculates KB answered percentage', async () => {
|
|
150
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
|
|
151
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8 }));
|
|
152
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.2 }));
|
|
153
|
+
const summary = await store.getSummary();
|
|
154
|
+
// 2 out of 3 have kbTopScore >= 0.7
|
|
155
|
+
expect(summary.kbAnsweredPct).toBeCloseTo(66.67, 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('calculates no-answer percentage', async () => {
|
|
159
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.1 }));
|
|
160
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
|
|
161
|
+
const summary = await store.getSummary();
|
|
162
|
+
// 1 out of 2 has kbTopScore < 0.3
|
|
163
|
+
expect(summary.noAnswerPct).toBe(50);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('calculates FAQ hit rate', async () => {
|
|
167
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true }));
|
|
168
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false }));
|
|
169
|
+
const summary = await store.getSummary();
|
|
170
|
+
expect(summary.faqHitRate).toBe(50);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('calculates average response time', async () => {
|
|
174
|
+
await store.recordMessageEvent(makeEvent({ responseTimeMs: 200 }));
|
|
175
|
+
await store.recordMessageEvent(makeEvent({ responseTimeMs: 400 }));
|
|
176
|
+
const summary = await store.getSummary();
|
|
177
|
+
expect(summary.avgResponseTime).toBe(300);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('builds agent breakdown', async () => {
|
|
181
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support' }));
|
|
182
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support' }));
|
|
183
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'billing' }));
|
|
184
|
+
const summary = await store.getSummary();
|
|
185
|
+
expect(summary.agentBreakdown).toEqual({ support: 2, billing: 1 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('identifies peak day', async () => {
|
|
189
|
+
const day1 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
190
|
+
const day2 = new Date('2026-01-16T10:00:00Z').getTime();
|
|
191
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day1 }));
|
|
192
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day2 }));
|
|
193
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day2 }));
|
|
194
|
+
const summary = await store.getSummary();
|
|
195
|
+
expect(summary.peakDay).toBe('2026-01-16');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('calculates avgPerDay correctly', async () => {
|
|
199
|
+
const day1 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
200
|
+
const day2 = new Date('2026-01-16T10:00:00Z').getTime();
|
|
201
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day1 }));
|
|
202
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day1 }));
|
|
203
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day2 }));
|
|
204
|
+
const summary = await store.getSummary();
|
|
205
|
+
// 3 messages over 2 days
|
|
206
|
+
expect(summary.avgPerDay).toBe(1.5);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('filters by time range', async () => {
|
|
210
|
+
const t1 = new Date('2026-01-10T10:00:00Z').getTime();
|
|
211
|
+
const t2 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
212
|
+
const t3 = new Date('2026-01-20T10:00:00Z').getTime();
|
|
213
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
|
|
214
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
|
|
215
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t3 }));
|
|
216
|
+
|
|
217
|
+
const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
|
|
218
|
+
const summary = await store.getSummary(range);
|
|
219
|
+
expect(summary.totalMessages).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// --- getMessageVolume ---
|
|
224
|
+
|
|
225
|
+
describe('getMessageVolume', () => {
|
|
226
|
+
it('returns empty array for empty store', async () => {
|
|
227
|
+
const volume = await store.getMessageVolume();
|
|
228
|
+
expect(volume).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('groups by day', async () => {
|
|
232
|
+
const day1 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
233
|
+
const day1b = new Date('2026-01-15T14:00:00Z').getTime();
|
|
234
|
+
const day2 = new Date('2026-01-16T10:00:00Z').getTime();
|
|
235
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day1 }));
|
|
236
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day1b }));
|
|
237
|
+
await store.recordMessageEvent(makeEvent({ timestamp: day2 }));
|
|
238
|
+
|
|
239
|
+
const volume = await store.getMessageVolume(undefined, 'day');
|
|
240
|
+
expect(volume).toHaveLength(2);
|
|
241
|
+
expect(volume[0].count).toBe(2);
|
|
242
|
+
expect(volume[1].count).toBe(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('groups by hour', async () => {
|
|
246
|
+
const h1 = new Date('2026-01-15T10:15:00Z').getTime();
|
|
247
|
+
const h1b = new Date('2026-01-15T10:45:00Z').getTime();
|
|
248
|
+
const h2 = new Date('2026-01-15T11:15:00Z').getTime();
|
|
249
|
+
await store.recordMessageEvent(makeEvent({ timestamp: h1 }));
|
|
250
|
+
await store.recordMessageEvent(makeEvent({ timestamp: h1b }));
|
|
251
|
+
await store.recordMessageEvent(makeEvent({ timestamp: h2 }));
|
|
252
|
+
|
|
253
|
+
const volume = await store.getMessageVolume(undefined, 'hour');
|
|
254
|
+
expect(volume).toHaveLength(2);
|
|
255
|
+
// First hour bucket has 2 messages
|
|
256
|
+
expect(volume[0].count).toBe(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('respects time range filter', async () => {
|
|
260
|
+
const t1 = new Date('2026-01-10T10:00:00Z').getTime();
|
|
261
|
+
const t2 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
262
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
|
|
263
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
|
|
264
|
+
|
|
265
|
+
const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
|
|
266
|
+
const volume = await store.getMessageVolume(range);
|
|
267
|
+
expect(volume).toHaveLength(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns sorted by date', async () => {
|
|
271
|
+
const t2 = new Date('2026-01-16T10:00:00Z').getTime();
|
|
272
|
+
const t1 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
273
|
+
// Insert out of order
|
|
274
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t2 }));
|
|
275
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t1 }));
|
|
276
|
+
|
|
277
|
+
const volume = await store.getMessageVolume();
|
|
278
|
+
expect(volume[0].date < volume[1].date).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// --- getAgentStats ---
|
|
283
|
+
|
|
284
|
+
describe('getAgentStats', () => {
|
|
285
|
+
it('returns empty array for empty store', async () => {
|
|
286
|
+
const stats = await store.getAgentStats();
|
|
287
|
+
expect(stats).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('aggregates per agent', async () => {
|
|
291
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', responseTimeMs: 200, intentConfidence: 0.9 }));
|
|
292
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', responseTimeMs: 400, intentConfidence: 0.8 }));
|
|
293
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'billing', responseTimeMs: 300, intentConfidence: 0.7 }));
|
|
294
|
+
|
|
295
|
+
const stats = await store.getAgentStats();
|
|
296
|
+
expect(stats).toHaveLength(2);
|
|
297
|
+
|
|
298
|
+
const support = stats.find(s => s.agentName === 'support')!;
|
|
299
|
+
expect(support.messageCount).toBe(2);
|
|
300
|
+
expect(support.avgResponseTimeMs).toBe(300);
|
|
301
|
+
expect(support.avgConfidence).toBeCloseTo(0.85);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('counts escalations per agent', async () => {
|
|
305
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', wasEscalated: true }));
|
|
306
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', wasEscalated: false }));
|
|
307
|
+
const stats = await store.getAgentStats();
|
|
308
|
+
const support = stats.find(s => s.agentName === 'support')!;
|
|
309
|
+
expect(support.escalationCount).toBe(1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('sums tool calls per agent', async () => {
|
|
313
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', toolCallsCount: 3 }));
|
|
314
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', toolCallsCount: 2 }));
|
|
315
|
+
const stats = await store.getAgentStats();
|
|
316
|
+
const support = stats.find(s => s.agentName === 'support')!;
|
|
317
|
+
expect(support.toolCallCount).toBe(5);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('respects time range', async () => {
|
|
321
|
+
const t1 = new Date('2026-01-10T10:00:00Z').getTime();
|
|
322
|
+
const t2 = new Date('2026-01-20T10:00:00Z').getTime();
|
|
323
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t1, agentName: 'support' }));
|
|
324
|
+
await store.recordMessageEvent(makeEvent({ timestamp: t2, agentName: 'billing' }));
|
|
325
|
+
|
|
326
|
+
const range: TimeRange = { from: t2 - 1000, to: t2 + 1000 };
|
|
327
|
+
const stats = await store.getAgentStats(range);
|
|
328
|
+
expect(stats).toHaveLength(1);
|
|
329
|
+
expect(stats[0].agentName).toBe('billing');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// --- getCustomerStats ---
|
|
334
|
+
|
|
335
|
+
describe('getCustomerStats', () => {
|
|
336
|
+
it('returns empty array for empty store', async () => {
|
|
337
|
+
const stats = await store.getCustomerStats();
|
|
338
|
+
expect(stats).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('aggregates per customer', async () => {
|
|
342
|
+
const t1 = new Date('2026-01-15T10:00:00Z').getTime();
|
|
343
|
+
const t2 = new Date('2026-01-15T14:00:00Z').getTime();
|
|
344
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1', customerId: 'c1', timestamp: t1 }));
|
|
345
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1', customerId: 'c1', timestamp: t2 }));
|
|
346
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+2', customerId: 'c2', timestamp: t1 }));
|
|
347
|
+
|
|
348
|
+
const stats = await store.getCustomerStats();
|
|
349
|
+
expect(stats).toHaveLength(2);
|
|
350
|
+
// Sorted by message count desc
|
|
351
|
+
expect(stats[0].customerPhone).toBe('+1');
|
|
352
|
+
expect(stats[0].messageCount).toBe(2);
|
|
353
|
+
expect(stats[0].firstSeen).toBe(t1);
|
|
354
|
+
expect(stats[0].lastSeen).toBe(t2);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('respects limit', async () => {
|
|
358
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1' }));
|
|
359
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+2' }));
|
|
360
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+3' }));
|
|
361
|
+
|
|
362
|
+
const stats = await store.getCustomerStats(undefined, 2);
|
|
363
|
+
expect(stats).toHaveLength(2);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('tracks isNew flag', async () => {
|
|
367
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1', isNewCustomer: true }));
|
|
368
|
+
await store.recordMessageEvent(makeEvent({ customerPhone: '+1', isNewCustomer: false }));
|
|
369
|
+
const stats = await store.getCustomerStats();
|
|
370
|
+
// isNew should be true if any event had isNewCustomer=true
|
|
371
|
+
expect(stats[0].isNew).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// --- getKbStats ---
|
|
376
|
+
|
|
377
|
+
describe('getKbStats', () => {
|
|
378
|
+
it('returns zeros for empty store', async () => {
|
|
379
|
+
const kb = await store.getKbStats();
|
|
380
|
+
expect(kb.totalQueries).toBe(0);
|
|
381
|
+
expect(kb.faqHits).toBe(0);
|
|
382
|
+
expect(kb.faqHitRate).toBe(0);
|
|
383
|
+
expect(kb.avgTopScore).toBe(0);
|
|
384
|
+
expect(kb.topFaqHits).toEqual([]);
|
|
385
|
+
expect(kb.topMisses).toEqual([]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('calculates FAQ hit rate', async () => {
|
|
389
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, kbTopScore: 0.9 }));
|
|
390
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, kbTopScore: 0.85 }));
|
|
391
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false, kbTopScore: 0.4 }));
|
|
392
|
+
|
|
393
|
+
const kb = await store.getKbStats();
|
|
394
|
+
expect(kb.totalQueries).toBe(3);
|
|
395
|
+
expect(kb.faqHits).toBe(2);
|
|
396
|
+
expect(kb.faqHitRate).toBeCloseTo(66.67, 0);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('calculates average top score', async () => {
|
|
400
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8 }));
|
|
401
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.6 }));
|
|
402
|
+
const kb = await store.getKbStats();
|
|
403
|
+
expect(kb.avgTopScore).toBeCloseTo(0.7);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// --- getTopFaqHits ---
|
|
408
|
+
|
|
409
|
+
describe('getTopFaqHits', () => {
|
|
410
|
+
it('returns empty for no FAQ matches', async () => {
|
|
411
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: false }));
|
|
412
|
+
const hits = await store.getTopFaqHits();
|
|
413
|
+
expect(hits).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('groups by intent and sorts by count', async () => {
|
|
417
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'hours' }));
|
|
418
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'hours' }));
|
|
419
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'returns' }));
|
|
420
|
+
|
|
421
|
+
const hits = await store.getTopFaqHits();
|
|
422
|
+
expect(hits[0]).toEqual({ intent: 'hours', count: 2 });
|
|
423
|
+
expect(hits[1]).toEqual({ intent: 'returns', count: 1 });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('respects limit', async () => {
|
|
427
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'a' }));
|
|
428
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'b' }));
|
|
429
|
+
await store.recordMessageEvent(makeEvent({ kbIsFaqMatch: true, intent: 'c' }));
|
|
430
|
+
|
|
431
|
+
const hits = await store.getTopFaqHits(2);
|
|
432
|
+
expect(hits).toHaveLength(2);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// --- getTopMisses ---
|
|
437
|
+
|
|
438
|
+
describe('getTopMisses', () => {
|
|
439
|
+
it('returns empty when no low-score events', async () => {
|
|
440
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.9 }));
|
|
441
|
+
const misses = await store.getTopMisses();
|
|
442
|
+
expect(misses).toEqual([]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('captures low-score events (< 0.5 and > 0)', async () => {
|
|
446
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.3, intent: 'unknown_q' }));
|
|
447
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.2, intent: 'unknown_q' }));
|
|
448
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.4, intent: 'other_q' }));
|
|
449
|
+
|
|
450
|
+
const misses = await store.getTopMisses();
|
|
451
|
+
expect(misses).toHaveLength(2);
|
|
452
|
+
expect(misses[0].intent).toBe('unknown_q');
|
|
453
|
+
expect(misses[0].count).toBe(2);
|
|
454
|
+
expect(misses[0].avgScore).toBeCloseTo(0.25);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('excludes zero-score events', async () => {
|
|
458
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0, intent: 'no_kb' }));
|
|
459
|
+
const misses = await store.getTopMisses();
|
|
460
|
+
expect(misses).toEqual([]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('excludes high-score events', async () => {
|
|
464
|
+
await store.recordMessageEvent(makeEvent({ kbTopScore: 0.8, intent: 'good' }));
|
|
465
|
+
const misses = await store.getTopMisses();
|
|
466
|
+
expect(misses).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// --- Edge cases ---
|
|
471
|
+
|
|
472
|
+
describe('edge cases', () => {
|
|
473
|
+
it('handles single message correctly', async () => {
|
|
474
|
+
await store.recordMessageEvent(makeEvent());
|
|
475
|
+
const summary = await store.getSummary();
|
|
476
|
+
expect(summary.totalMessages).toBe(1);
|
|
477
|
+
expect(summary.avgPerDay).toBe(1);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('handles multiple agents with same customer', async () => {
|
|
481
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'support', customerPhone: '+1' }));
|
|
482
|
+
await store.recordMessageEvent(makeEvent({ agentName: 'billing', customerPhone: '+1' }));
|
|
483
|
+
const summary = await store.getSummary();
|
|
484
|
+
expect(summary.uniqueCustomers).toBe(1);
|
|
485
|
+
expect(summary.agentBreakdown).toEqual({ support: 1, billing: 1 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('initialize and close are no-ops', async () => {
|
|
489
|
+
const s = new InMemoryAnalyticsStore();
|
|
490
|
+
await s.initialize();
|
|
491
|
+
await s.close();
|
|
492
|
+
// Should not throw
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
});
|