@operor/copilot 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 +282 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +708 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -0
- package/src/CopilotCommandHandler.ts +263 -0
- package/src/DigestScheduler.ts +76 -0
- package/src/InMemoryCopilotStore.ts +90 -0
- package/src/QueryClusterer.ts +84 -0
- package/src/SQLiteCopilotStore.ts +300 -0
- package/src/SuggestionEngine.ts +44 -0
- package/src/UnansweredQueryTracker.ts +83 -0
- package/src/__tests__/copilot.test.ts +1007 -0
- package/src/index.ts +8 -0
- package/src/types.ts +131 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for @operor/copilot
|
|
3
|
+
*
|
|
4
|
+
* Covers: InMemoryCopilotStore, UnansweredQueryTracker, QueryClusterer,
|
|
5
|
+
* CopilotCommandHandler, DigestScheduler
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { InMemoryCopilotStore } from '../InMemoryCopilotStore.js';
|
|
10
|
+
import { UnansweredQueryTracker } from '../UnansweredQueryTracker.js';
|
|
11
|
+
import { QueryClusterer } from '../QueryClusterer.js';
|
|
12
|
+
import { CopilotCommandHandler } from '../CopilotCommandHandler.js';
|
|
13
|
+
import { DigestScheduler } from '../DigestScheduler.js';
|
|
14
|
+
import type {
|
|
15
|
+
UnansweredQuery,
|
|
16
|
+
EmbeddingService,
|
|
17
|
+
CopilotStore,
|
|
18
|
+
MessageProcessedEvent,
|
|
19
|
+
} from '../types.js';
|
|
20
|
+
import type { KnowledgeBaseRuntime } from '@operor/core';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function createMockEmbedder(dim = 4): EmbeddingService {
|
|
27
|
+
return {
|
|
28
|
+
dimensions: dim,
|
|
29
|
+
embed: async (text: string) => {
|
|
30
|
+
const hash = text.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
31
|
+
return Array.from({ length: dim }, (_, i) => Math.sin(hash + i));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createTestQuery(overrides?: Partial<UnansweredQuery>): UnansweredQuery {
|
|
37
|
+
return {
|
|
38
|
+
id: crypto.randomUUID(),
|
|
39
|
+
query: 'test query',
|
|
40
|
+
normalizedQuery: 'test query',
|
|
41
|
+
channel: 'whatsapp',
|
|
42
|
+
customerPhone: '+1234567890',
|
|
43
|
+
kbTopScore: 0.3,
|
|
44
|
+
kbIsFaqMatch: false,
|
|
45
|
+
kbResultCount: 1,
|
|
46
|
+
status: 'pending',
|
|
47
|
+
timesAsked: 1,
|
|
48
|
+
uniqueCustomers: ['+1234567890'],
|
|
49
|
+
createdAt: Date.now(),
|
|
50
|
+
updatedAt: Date.now(),
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createMockKb(): KnowledgeBaseRuntime {
|
|
56
|
+
return {
|
|
57
|
+
ingestFaq: vi.fn().mockResolvedValue({ id: 'test-id' }),
|
|
58
|
+
retrieve: vi.fn().mockResolvedValue({ results: [], context: '', isFaqMatch: false }),
|
|
59
|
+
listDocuments: vi.fn().mockResolvedValue([]),
|
|
60
|
+
deleteDocument: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
getStats: vi.fn().mockResolvedValue({
|
|
62
|
+
documentCount: 0,
|
|
63
|
+
chunkCount: 0,
|
|
64
|
+
embeddingDimensions: 4,
|
|
65
|
+
dbSizeBytes: 0,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createEvent(overrides?: Partial<MessageProcessedEvent>): MessageProcessedEvent {
|
|
71
|
+
return {
|
|
72
|
+
query: 'What is your return policy?',
|
|
73
|
+
channel: 'whatsapp',
|
|
74
|
+
customerPhone: '+1234567890',
|
|
75
|
+
response: {
|
|
76
|
+
text: 'I am not sure about that.',
|
|
77
|
+
metadata: {
|
|
78
|
+
kbTopScore: 0.3,
|
|
79
|
+
kbIsFaqMatch: false,
|
|
80
|
+
kbResultCount: 1,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// 1. InMemoryCopilotStore
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
describe('InMemoryCopilotStore', () => {
|
|
92
|
+
let store: InMemoryCopilotStore;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
store = new InMemoryCopilotStore();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('addQuery + getQuery stores and retrieves a query', async () => {
|
|
99
|
+
const q = createTestQuery({ id: 'q1', query: 'How to return?' });
|
|
100
|
+
await store.addQuery(q);
|
|
101
|
+
|
|
102
|
+
const result = await store.getQuery('q1');
|
|
103
|
+
expect(result).not.toBeNull();
|
|
104
|
+
expect(result!.id).toBe('q1');
|
|
105
|
+
expect(result!.query).toBe('How to return?');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('getQuery returns null for unknown id', async () => {
|
|
109
|
+
const result = await store.getQuery('nonexistent');
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('getPendingQueries returns only pending, sorted by timesAsked DESC', async () => {
|
|
114
|
+
await store.addQuery(createTestQuery({ id: 'q1', status: 'pending', timesAsked: 2, query: 'A' }));
|
|
115
|
+
await store.addQuery(createTestQuery({ id: 'q2', status: 'taught', timesAsked: 5, query: 'B' }));
|
|
116
|
+
await store.addQuery(createTestQuery({ id: 'q3', status: 'pending', timesAsked: 10, query: 'C' }));
|
|
117
|
+
await store.addQuery(createTestQuery({ id: 'q4', status: 'dismissed', timesAsked: 3, query: 'D' }));
|
|
118
|
+
await store.addQuery(createTestQuery({ id: 'q5', status: 'pending', timesAsked: 1, query: 'E' }));
|
|
119
|
+
|
|
120
|
+
const pending = await store.getPendingQueries();
|
|
121
|
+
expect(pending).toHaveLength(3);
|
|
122
|
+
expect(pending[0].id).toBe('q3'); // timesAsked=10
|
|
123
|
+
expect(pending[1].id).toBe('q1'); // timesAsked=2
|
|
124
|
+
expect(pending[2].id).toBe('q5'); // timesAsked=1
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('getPendingQueries respects limit', async () => {
|
|
128
|
+
await store.addQuery(createTestQuery({ id: 'q1', status: 'pending', timesAsked: 5 }));
|
|
129
|
+
await store.addQuery(createTestQuery({ id: 'q2', status: 'pending', timesAsked: 3 }));
|
|
130
|
+
await store.addQuery(createTestQuery({ id: 'q3', status: 'pending', timesAsked: 1 }));
|
|
131
|
+
|
|
132
|
+
const pending = await store.getPendingQueries(2);
|
|
133
|
+
expect(pending).toHaveLength(2);
|
|
134
|
+
expect(pending[0].id).toBe('q1');
|
|
135
|
+
expect(pending[1].id).toBe('q2');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('updateQuery updates fields and updatedAt', async () => {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const q = createTestQuery({ id: 'q1', timesAsked: 1, updatedAt: now - 10_000 });
|
|
141
|
+
await store.addQuery(q);
|
|
142
|
+
|
|
143
|
+
await store.updateQuery('q1', { timesAsked: 5, status: 'taught' });
|
|
144
|
+
|
|
145
|
+
const updated = await store.getQuery('q1');
|
|
146
|
+
expect(updated!.timesAsked).toBe(5);
|
|
147
|
+
expect(updated!.status).toBe('taught');
|
|
148
|
+
expect(updated!.updatedAt).toBeGreaterThanOrEqual(now);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('updateQuery does nothing for unknown id', async () => {
|
|
152
|
+
// Should not throw
|
|
153
|
+
await store.updateQuery('nonexistent', { timesAsked: 99 });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('findSimilarQuery matches by normalizedQuery', async () => {
|
|
157
|
+
await store.addQuery(createTestQuery({ id: 'q1', normalizedQuery: 'return policy' }));
|
|
158
|
+
await store.addQuery(createTestQuery({ id: 'q2', normalizedQuery: 'shipping cost' }));
|
|
159
|
+
|
|
160
|
+
const match = await store.findSimilarQuery('return policy');
|
|
161
|
+
expect(match).not.toBeNull();
|
|
162
|
+
expect(match!.id).toBe('q1');
|
|
163
|
+
|
|
164
|
+
const noMatch = await store.findSimilarQuery('payment methods');
|
|
165
|
+
expect(noMatch).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('addCluster + getCluster stores and retrieves a cluster', async () => {
|
|
169
|
+
await store.addCluster({
|
|
170
|
+
id: 'c1',
|
|
171
|
+
representativeQuery: 'return policy',
|
|
172
|
+
queryCount: 1,
|
|
173
|
+
uniqueCustomers: [],
|
|
174
|
+
status: 'pending',
|
|
175
|
+
createdAt: Date.now(),
|
|
176
|
+
updatedAt: Date.now(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const cluster = await store.getCluster('c1');
|
|
180
|
+
expect(cluster).not.toBeNull();
|
|
181
|
+
expect(cluster!.representativeQuery).toBe('return policy');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('getCluster returns null for unknown id', async () => {
|
|
185
|
+
expect(await store.getCluster('nonexistent')).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('getOpenClusters returns only pending clusters', async () => {
|
|
189
|
+
await store.addCluster({
|
|
190
|
+
id: 'c1', representativeQuery: 'A', queryCount: 1,
|
|
191
|
+
uniqueCustomers: [], status: 'pending', createdAt: Date.now(), updatedAt: Date.now(),
|
|
192
|
+
});
|
|
193
|
+
await store.addCluster({
|
|
194
|
+
id: 'c2', representativeQuery: 'B', queryCount: 2,
|
|
195
|
+
uniqueCustomers: [], status: 'taught', createdAt: Date.now(), updatedAt: Date.now(),
|
|
196
|
+
});
|
|
197
|
+
await store.addCluster({
|
|
198
|
+
id: 'c3', representativeQuery: 'C', queryCount: 3,
|
|
199
|
+
uniqueCustomers: [], status: 'pending', createdAt: Date.now(), updatedAt: Date.now(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const open = await store.getOpenClusters();
|
|
203
|
+
expect(open).toHaveLength(2);
|
|
204
|
+
const ids = open.map(c => c.id).sort();
|
|
205
|
+
expect(ids).toEqual(['c1', 'c3']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('updateCluster updates fields and updatedAt', async () => {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
await store.addCluster({
|
|
211
|
+
id: 'c1', representativeQuery: 'A', queryCount: 1,
|
|
212
|
+
uniqueCustomers: [], status: 'pending', createdAt: now, updatedAt: now - 10_000,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await store.updateCluster('c1', { queryCount: 5, label: 'returns' });
|
|
216
|
+
|
|
217
|
+
const cluster = await store.getCluster('c1');
|
|
218
|
+
expect(cluster!.queryCount).toBe(5);
|
|
219
|
+
expect(cluster!.label).toBe('returns');
|
|
220
|
+
expect(cluster!.updatedAt).toBeGreaterThanOrEqual(now);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('getQueriesByCluster returns queries with matching clusterId', async () => {
|
|
224
|
+
await store.addQuery(createTestQuery({ id: 'q1', clusterId: 'c1' }));
|
|
225
|
+
await store.addQuery(createTestQuery({ id: 'q2', clusterId: 'c2' }));
|
|
226
|
+
await store.addQuery(createTestQuery({ id: 'q3', clusterId: 'c1' }));
|
|
227
|
+
|
|
228
|
+
const result = await store.getQueriesByCluster('c1');
|
|
229
|
+
expect(result).toHaveLength(2);
|
|
230
|
+
const ids = result.map(q => q.id).sort();
|
|
231
|
+
expect(ids).toEqual(['q1', 'q3']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('getImpactMetrics counts correctly', async () => {
|
|
235
|
+
await store.addQuery(createTestQuery({
|
|
236
|
+
id: 'q1', status: 'pending', timesAsked: 3,
|
|
237
|
+
uniqueCustomers: ['+111', '+222'],
|
|
238
|
+
}));
|
|
239
|
+
await store.addQuery(createTestQuery({
|
|
240
|
+
id: 'q2', status: 'taught', timesAsked: 5,
|
|
241
|
+
uniqueCustomers: ['+222', '+333'],
|
|
242
|
+
}));
|
|
243
|
+
await store.addQuery(createTestQuery({
|
|
244
|
+
id: 'q3', status: 'dismissed', timesAsked: 2,
|
|
245
|
+
uniqueCustomers: ['+111'],
|
|
246
|
+
}));
|
|
247
|
+
await store.addQuery(createTestQuery({
|
|
248
|
+
id: 'q4', status: 'pending', timesAsked: 7,
|
|
249
|
+
uniqueCustomers: ['+444'],
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
const metrics = await store.getImpactMetrics();
|
|
253
|
+
expect(metrics.pendingCount).toBe(2);
|
|
254
|
+
expect(metrics.taughtCount).toBe(1);
|
|
255
|
+
expect(metrics.dismissedCount).toBe(1);
|
|
256
|
+
expect(metrics.totalCustomersAffected).toBe(4); // +111, +222, +333, +444
|
|
257
|
+
expect(metrics.totalTimesAsked).toBe(17); // 3+5+2+7
|
|
258
|
+
expect(metrics.topPendingQueries).toHaveLength(2);
|
|
259
|
+
// Sorted by timesAsked DESC
|
|
260
|
+
expect(metrics.topPendingQueries[0].id).toBe('q4');
|
|
261
|
+
expect(metrics.topPendingQueries[1].id).toBe('q1');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('getImpactMetrics respects topN limit', async () => {
|
|
265
|
+
for (let i = 0; i < 5; i++) {
|
|
266
|
+
await store.addQuery(createTestQuery({
|
|
267
|
+
id: `q${i}`, status: 'pending', timesAsked: i + 1,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const metrics = await store.getImpactMetrics(2);
|
|
272
|
+
expect(metrics.topPendingQueries).toHaveLength(2);
|
|
273
|
+
expect(metrics.topPendingQueries[0].timesAsked).toBe(5);
|
|
274
|
+
expect(metrics.topPendingQueries[1].timesAsked).toBe(4);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('getLastDigestTime / setLastDigestTime', async () => {
|
|
278
|
+
expect(await store.getLastDigestTime()).toBe(0);
|
|
279
|
+
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
await store.setLastDigestTime(now);
|
|
282
|
+
expect(await store.getLastDigestTime()).toBe(now);
|
|
283
|
+
|
|
284
|
+
await store.setLastDigestTime(now + 1000);
|
|
285
|
+
expect(await store.getLastDigestTime()).toBe(now + 1000);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// 2. UnansweredQueryTracker
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
describe('UnansweredQueryTracker', () => {
|
|
294
|
+
let store: InMemoryCopilotStore;
|
|
295
|
+
let embedder: EmbeddingService;
|
|
296
|
+
let tracker: UnansweredQueryTracker;
|
|
297
|
+
|
|
298
|
+
beforeEach(() => {
|
|
299
|
+
store = new InMemoryCopilotStore();
|
|
300
|
+
embedder = createMockEmbedder();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('tracks queries below threshold', async () => {
|
|
304
|
+
tracker = new UnansweredQueryTracker(
|
|
305
|
+
store,
|
|
306
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
307
|
+
embedder,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
await tracker.maybeTrack(createEvent({
|
|
311
|
+
query: 'What is your return policy?',
|
|
312
|
+
response: {
|
|
313
|
+
text: 'Not sure.',
|
|
314
|
+
metadata: { kbTopScore: 0.4, kbIsFaqMatch: false, kbResultCount: 1 },
|
|
315
|
+
},
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
const pending = await store.getPendingQueries();
|
|
319
|
+
expect(pending).toHaveLength(1);
|
|
320
|
+
expect(pending[0].query).toBe('What is your return policy?');
|
|
321
|
+
expect(pending[0].status).toBe('pending');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('skips queries above threshold', async () => {
|
|
325
|
+
tracker = new UnansweredQueryTracker(
|
|
326
|
+
store,
|
|
327
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
328
|
+
embedder,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await tracker.maybeTrack(createEvent({
|
|
332
|
+
response: {
|
|
333
|
+
text: 'Here is our return policy...',
|
|
334
|
+
metadata: { kbTopScore: 0.85, kbIsFaqMatch: false, kbResultCount: 2 },
|
|
335
|
+
},
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
const pending = await store.getPendingQueries();
|
|
339
|
+
expect(pending).toHaveLength(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('skips when kbIsFaqMatch is true and score is above threshold', async () => {
|
|
343
|
+
tracker = new UnansweredQueryTracker(
|
|
344
|
+
store,
|
|
345
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
346
|
+
embedder,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await tracker.maybeTrack(createEvent({
|
|
350
|
+
response: {
|
|
351
|
+
text: 'FAQ answer.',
|
|
352
|
+
metadata: { kbTopScore: 0.90, kbIsFaqMatch: true, kbResultCount: 1 },
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
|
|
356
|
+
const pending = await store.getPendingQueries();
|
|
357
|
+
expect(pending).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('tracks when kbResultCount is 0 even if kbTopScore defaults to 0', async () => {
|
|
361
|
+
tracker = new UnansweredQueryTracker(
|
|
362
|
+
store,
|
|
363
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
364
|
+
embedder,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await tracker.maybeTrack(createEvent({
|
|
368
|
+
response: {
|
|
369
|
+
text: 'No results.',
|
|
370
|
+
metadata: { kbTopScore: 0, kbIsFaqMatch: false, kbResultCount: 0 },
|
|
371
|
+
},
|
|
372
|
+
}));
|
|
373
|
+
|
|
374
|
+
const pending = await store.getPendingQueries();
|
|
375
|
+
expect(pending).toHaveLength(1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('increments timesAsked for duplicate normalized queries', async () => {
|
|
379
|
+
tracker = new UnansweredQueryTracker(
|
|
380
|
+
store,
|
|
381
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
382
|
+
embedder,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const event = createEvent({ query: 'return policy' });
|
|
386
|
+
|
|
387
|
+
await tracker.maybeTrack(event);
|
|
388
|
+
await tracker.maybeTrack(event);
|
|
389
|
+
await tracker.maybeTrack(event);
|
|
390
|
+
|
|
391
|
+
const pending = await store.getPendingQueries();
|
|
392
|
+
expect(pending).toHaveLength(1);
|
|
393
|
+
expect(pending[0].timesAsked).toBe(3);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('adds new customerPhone to uniqueCustomers on duplicate', async () => {
|
|
397
|
+
tracker = new UnansweredQueryTracker(
|
|
398
|
+
store,
|
|
399
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
400
|
+
embedder,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
await tracker.maybeTrack(createEvent({
|
|
404
|
+
query: 'return policy',
|
|
405
|
+
customerPhone: '+111',
|
|
406
|
+
}));
|
|
407
|
+
await tracker.maybeTrack(createEvent({
|
|
408
|
+
query: 'return policy',
|
|
409
|
+
customerPhone: '+222',
|
|
410
|
+
}));
|
|
411
|
+
// Same phone again -- should not duplicate
|
|
412
|
+
await tracker.maybeTrack(createEvent({
|
|
413
|
+
query: 'return policy',
|
|
414
|
+
customerPhone: '+111',
|
|
415
|
+
}));
|
|
416
|
+
|
|
417
|
+
const pending = await store.getPendingQueries();
|
|
418
|
+
expect(pending).toHaveLength(1);
|
|
419
|
+
expect(pending[0].uniqueCustomers).toHaveLength(2);
|
|
420
|
+
expect(pending[0].uniqueCustomers).toContain('+111');
|
|
421
|
+
expect(pending[0].uniqueCustomers).toContain('+222');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('does not track when disabled', async () => {
|
|
425
|
+
tracker = new UnansweredQueryTracker(
|
|
426
|
+
store,
|
|
427
|
+
{ enabled: false, trackingThreshold: 0.70 },
|
|
428
|
+
embedder,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
await tracker.maybeTrack(createEvent());
|
|
432
|
+
|
|
433
|
+
const pending = await store.getPendingQueries();
|
|
434
|
+
expect(pending).toHaveLength(0);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('catches errors silently (non-blocking)', async () => {
|
|
438
|
+
const brokenStore: CopilotStore = {
|
|
439
|
+
...store,
|
|
440
|
+
findSimilarQuery: () => { throw new Error('DB crash'); },
|
|
441
|
+
addQuery: () => { throw new Error('DB crash'); },
|
|
442
|
+
} as any;
|
|
443
|
+
|
|
444
|
+
tracker = new UnansweredQueryTracker(
|
|
445
|
+
brokenStore,
|
|
446
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
447
|
+
embedder,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Should not throw
|
|
451
|
+
await expect(tracker.maybeTrack(createEvent())).resolves.toBeUndefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('calls clusterer.assignCluster for new queries', async () => {
|
|
455
|
+
const mockClusterer = {
|
|
456
|
+
assignCluster: vi.fn().mockResolvedValue('cluster-1'),
|
|
457
|
+
} as any;
|
|
458
|
+
|
|
459
|
+
tracker = new UnansweredQueryTracker(
|
|
460
|
+
store,
|
|
461
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
462
|
+
embedder,
|
|
463
|
+
mockClusterer,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
await tracker.maybeTrack(createEvent({ query: 'new question' }));
|
|
467
|
+
|
|
468
|
+
expect(mockClusterer.assignCluster).toHaveBeenCalledTimes(1);
|
|
469
|
+
const [queryId, normalizedText, embedding] = mockClusterer.assignCluster.mock.calls[0];
|
|
470
|
+
expect(typeof queryId).toBe('string');
|
|
471
|
+
expect(normalizedText).toBe('new question');
|
|
472
|
+
expect(embedding).toHaveLength(4); // mock embedder dim
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('does not call clusterer for duplicate queries', async () => {
|
|
476
|
+
const mockClusterer = {
|
|
477
|
+
assignCluster: vi.fn().mockResolvedValue('cluster-1'),
|
|
478
|
+
} as any;
|
|
479
|
+
|
|
480
|
+
tracker = new UnansweredQueryTracker(
|
|
481
|
+
store,
|
|
482
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
483
|
+
embedder,
|
|
484
|
+
mockClusterer,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await tracker.maybeTrack(createEvent({ query: 'same question' }));
|
|
488
|
+
await tracker.maybeTrack(createEvent({ query: 'same question' }));
|
|
489
|
+
|
|
490
|
+
// assignCluster only called once (for the new query, not the duplicate)
|
|
491
|
+
expect(mockClusterer.assignCluster).toHaveBeenCalledTimes(1);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('normalizeQuery lowercases, trims, and collapses whitespace', () => {
|
|
495
|
+
tracker = new UnansweredQueryTracker(
|
|
496
|
+
store,
|
|
497
|
+
{ enabled: true, trackingThreshold: 0.70 },
|
|
498
|
+
embedder,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
expect(tracker.normalizeQuery(' HELLO WORLD ')).toBe('hello world');
|
|
502
|
+
expect(tracker.normalizeQuery('Return\t\nPolicy')).toBe('return policy');
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// 3. QueryClusterer
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
describe('QueryClusterer', () => {
|
|
511
|
+
let store: InMemoryCopilotStore;
|
|
512
|
+
let embedder: EmbeddingService;
|
|
513
|
+
let clusterer: QueryClusterer;
|
|
514
|
+
|
|
515
|
+
beforeEach(() => {
|
|
516
|
+
store = new InMemoryCopilotStore();
|
|
517
|
+
embedder = createMockEmbedder();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('creates new cluster for first query', async () => {
|
|
521
|
+
clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.87 });
|
|
522
|
+
|
|
523
|
+
const embedding = await embedder.embed('return policy');
|
|
524
|
+
const q = createTestQuery({ id: 'q1' });
|
|
525
|
+
await store.addQuery(q);
|
|
526
|
+
|
|
527
|
+
const clusterId = await clusterer.assignCluster('q1', 'return policy', embedding);
|
|
528
|
+
|
|
529
|
+
expect(typeof clusterId).toBe('string');
|
|
530
|
+
const cluster = await store.getCluster(clusterId);
|
|
531
|
+
expect(cluster).not.toBeNull();
|
|
532
|
+
expect(cluster!.representativeQuery).toBe('return policy');
|
|
533
|
+
expect(cluster!.queryCount).toBe(1);
|
|
534
|
+
expect(cluster!.status).toBe('pending');
|
|
535
|
+
|
|
536
|
+
// Query should be updated with clusterId
|
|
537
|
+
const updatedQ = await store.getQuery('q1');
|
|
538
|
+
expect(updatedQ!.clusterId).toBe(clusterId);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('assigns query to existing cluster when similarity >= threshold', async () => {
|
|
542
|
+
// Use a very low threshold so identical embeddings always match
|
|
543
|
+
clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.5 });
|
|
544
|
+
|
|
545
|
+
const embedding = await embedder.embed('return policy');
|
|
546
|
+
const q1 = createTestQuery({ id: 'q1' });
|
|
547
|
+
await store.addQuery(q1);
|
|
548
|
+
const clusterId1 = await clusterer.assignCluster('q1', 'return policy', embedding);
|
|
549
|
+
|
|
550
|
+
// Same text -> same embedding -> cosine similarity = 1.0 -> should match
|
|
551
|
+
const q2 = createTestQuery({ id: 'q2' });
|
|
552
|
+
await store.addQuery(q2);
|
|
553
|
+
const clusterId2 = await clusterer.assignCluster('q2', 'return policy', embedding);
|
|
554
|
+
|
|
555
|
+
expect(clusterId2).toBe(clusterId1);
|
|
556
|
+
|
|
557
|
+
const cluster = await store.getCluster(clusterId1);
|
|
558
|
+
expect(cluster!.queryCount).toBe(2);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('creates new cluster when similarity < threshold', async () => {
|
|
562
|
+
clusterer = new QueryClusterer(store, embedder, { clusterThreshold: 0.99 });
|
|
563
|
+
|
|
564
|
+
const embedding1 = await embedder.embed('return policy');
|
|
565
|
+
const q1 = createTestQuery({ id: 'q1' });
|
|
566
|
+
await store.addQuery(q1);
|
|
567
|
+
const clusterId1 = await clusterer.assignCluster('q1', 'return policy', embedding1);
|
|
568
|
+
|
|
569
|
+
// Very different text -> different embedding -> low similarity
|
|
570
|
+
const embedding2 = await embedder.embed('how to bake a cake from scratch');
|
|
571
|
+
const q2 = createTestQuery({ id: 'q2' });
|
|
572
|
+
await store.addQuery(q2);
|
|
573
|
+
const clusterId2 = await clusterer.assignCluster('q2', 'how to bake a cake from scratch', embedding2);
|
|
574
|
+
|
|
575
|
+
expect(clusterId2).not.toBe(clusterId1);
|
|
576
|
+
|
|
577
|
+
const open = await store.getOpenClusters();
|
|
578
|
+
expect(open).toHaveLength(2);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('updates centroid with running average', () => {
|
|
582
|
+
clusterer = new QueryClusterer(store, embedder);
|
|
583
|
+
|
|
584
|
+
const current = [1, 0, 0, 0];
|
|
585
|
+
const newVec = [0, 1, 0, 0];
|
|
586
|
+
// count=2 means this is the 2nd vector being added
|
|
587
|
+
const result = clusterer.updateCentroid(current, newVec, 2);
|
|
588
|
+
|
|
589
|
+
// (1*(2-1) + 0) / 2 = 0.5, (0*(2-1) + 1) / 2 = 0.5, 0, 0
|
|
590
|
+
expect(result[0]).toBeCloseTo(0.5);
|
|
591
|
+
expect(result[1]).toBeCloseTo(0.5);
|
|
592
|
+
expect(result[2]).toBeCloseTo(0);
|
|
593
|
+
expect(result[3]).toBeCloseTo(0);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('cosineSimilarity returns correct values', () => {
|
|
597
|
+
clusterer = new QueryClusterer(store, embedder);
|
|
598
|
+
|
|
599
|
+
// Identical vectors -> similarity = 1
|
|
600
|
+
expect(clusterer.cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1);
|
|
601
|
+
|
|
602
|
+
// Orthogonal vectors -> similarity = 0
|
|
603
|
+
expect(clusterer.cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0);
|
|
604
|
+
|
|
605
|
+
// Opposite vectors -> similarity = -1
|
|
606
|
+
expect(clusterer.cosineSimilarity([1, 0, 0], [-1, 0, 0])).toBeCloseTo(-1);
|
|
607
|
+
|
|
608
|
+
// Parallel but different magnitude -> similarity = 1
|
|
609
|
+
expect(clusterer.cosineSimilarity([2, 0, 0], [5, 0, 0])).toBeCloseTo(1);
|
|
610
|
+
|
|
611
|
+
// 45-degree angle
|
|
612
|
+
expect(clusterer.cosineSimilarity([1, 0], [1, 1])).toBeCloseTo(Math.SQRT1_2);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// 4. CopilotCommandHandler
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
describe('CopilotCommandHandler', () => {
|
|
621
|
+
let store: InMemoryCopilotStore;
|
|
622
|
+
let mockKb: KnowledgeBaseRuntime;
|
|
623
|
+
let handler: CopilotCommandHandler;
|
|
624
|
+
let replies: string[];
|
|
625
|
+
let reply: (text: string) => Promise<void>;
|
|
626
|
+
const adminPhone = '+admin';
|
|
627
|
+
|
|
628
|
+
beforeEach(() => {
|
|
629
|
+
store = new InMemoryCopilotStore();
|
|
630
|
+
mockKb = createMockKb();
|
|
631
|
+
handler = new CopilotCommandHandler(store, undefined, mockKb);
|
|
632
|
+
replies = [];
|
|
633
|
+
reply = async (text: string) => { replies.push(text); };
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe('/review (no args) and /review next', () => {
|
|
637
|
+
it('/review (no args) shows next pending query', async () => {
|
|
638
|
+
await store.addQuery(createTestQuery({
|
|
639
|
+
id: 'q1', query: 'How to return?', timesAsked: 3,
|
|
640
|
+
uniqueCustomers: ['+111', '+222', '+333'],
|
|
641
|
+
}));
|
|
642
|
+
|
|
643
|
+
await handler.handleCommand('/review', '', adminPhone, reply);
|
|
644
|
+
|
|
645
|
+
expect(replies).toHaveLength(1);
|
|
646
|
+
expect(replies[0]).toContain('How to return?');
|
|
647
|
+
expect(replies[0]).toContain('Times asked');
|
|
648
|
+
expect(replies[0]).toContain('3');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('/review next shows next pending query', async () => {
|
|
652
|
+
await store.addQuery(createTestQuery({
|
|
653
|
+
id: 'q1', query: 'Shipping cost?', timesAsked: 2,
|
|
654
|
+
uniqueCustomers: ['+111', '+222'],
|
|
655
|
+
}));
|
|
656
|
+
|
|
657
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
658
|
+
|
|
659
|
+
expect(replies).toHaveLength(1);
|
|
660
|
+
expect(replies[0]).toContain('Shipping cost?');
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('empty queue shows "No pending queries" message', async () => {
|
|
664
|
+
await handler.handleCommand('/review', '', adminPhone, reply);
|
|
665
|
+
|
|
666
|
+
expect(replies).toHaveLength(1);
|
|
667
|
+
expect(replies[0]).toContain('No pending queries');
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
describe('/review accept', () => {
|
|
672
|
+
it('teaches FAQ and marks as taught with suggested answer', async () => {
|
|
673
|
+
const q = createTestQuery({
|
|
674
|
+
id: 'q1',
|
|
675
|
+
query: 'How to return?',
|
|
676
|
+
suggestedAnswer: 'You can return within 30 days.',
|
|
677
|
+
});
|
|
678
|
+
await store.addQuery(q);
|
|
679
|
+
|
|
680
|
+
// Load the query first
|
|
681
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
682
|
+
replies = [];
|
|
683
|
+
|
|
684
|
+
// Accept with the suggested answer (no custom answer)
|
|
685
|
+
await handler.handleCommand('/review', 'accept', adminPhone, reply);
|
|
686
|
+
|
|
687
|
+
expect(replies).toHaveLength(1);
|
|
688
|
+
expect(replies[0]).toContain('Taught');
|
|
689
|
+
expect(replies[0]).toContain('How to return?');
|
|
690
|
+
expect(replies[0]).toContain('You can return within 30 days.');
|
|
691
|
+
|
|
692
|
+
expect(mockKb.ingestFaq).toHaveBeenCalledWith('How to return?', 'You can return within 30 days.');
|
|
693
|
+
|
|
694
|
+
const updated = await store.getQuery('q1');
|
|
695
|
+
expect(updated!.status).toBe('taught');
|
|
696
|
+
expect(updated!.taughtAnswer).toBe('You can return within 30 days.');
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('uses custom answer when provided', async () => {
|
|
700
|
+
const q = createTestQuery({
|
|
701
|
+
id: 'q1',
|
|
702
|
+
query: 'How to return?',
|
|
703
|
+
suggestedAnswer: 'Suggested answer.',
|
|
704
|
+
});
|
|
705
|
+
await store.addQuery(q);
|
|
706
|
+
|
|
707
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
708
|
+
replies = [];
|
|
709
|
+
|
|
710
|
+
await handler.handleCommand('/review', 'accept My custom answer here', adminPhone, reply);
|
|
711
|
+
|
|
712
|
+
expect(replies).toHaveLength(1);
|
|
713
|
+
expect(replies[0]).toContain('My custom answer here');
|
|
714
|
+
|
|
715
|
+
expect(mockKb.ingestFaq).toHaveBeenCalledWith('How to return?', 'My custom answer here');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it('shows error when no answer provided and no suggestion', async () => {
|
|
719
|
+
const q = createTestQuery({ id: 'q1', query: 'How to return?' });
|
|
720
|
+
// No suggestedAnswer
|
|
721
|
+
await store.addQuery(q);
|
|
722
|
+
|
|
723
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
724
|
+
replies = [];
|
|
725
|
+
|
|
726
|
+
await handler.handleCommand('/review', 'accept', adminPhone, reply);
|
|
727
|
+
|
|
728
|
+
expect(replies).toHaveLength(1);
|
|
729
|
+
expect(replies[0]).toContain('No answer provided');
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
describe('/review edit', () => {
|
|
734
|
+
it('teaches with provided answer', async () => {
|
|
735
|
+
const q = createTestQuery({ id: 'q1', query: 'What are your hours?' });
|
|
736
|
+
await store.addQuery(q);
|
|
737
|
+
|
|
738
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
739
|
+
replies = [];
|
|
740
|
+
|
|
741
|
+
await handler.handleCommand('/review', 'edit We are open 9-5 Mon-Fri', adminPhone, reply);
|
|
742
|
+
|
|
743
|
+
expect(replies).toHaveLength(1);
|
|
744
|
+
expect(replies[0]).toContain('Taught');
|
|
745
|
+
expect(replies[0]).toContain('We are open 9-5 Mon-Fri');
|
|
746
|
+
|
|
747
|
+
expect(mockKb.ingestFaq).toHaveBeenCalledWith('What are your hours?', 'We are open 9-5 Mon-Fri');
|
|
748
|
+
|
|
749
|
+
const updated = await store.getQuery('q1');
|
|
750
|
+
expect(updated!.status).toBe('taught');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('shows error without answer', async () => {
|
|
754
|
+
const q = createTestQuery({ id: 'q1', query: 'What are your hours?' });
|
|
755
|
+
await store.addQuery(q);
|
|
756
|
+
|
|
757
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
758
|
+
replies = [];
|
|
759
|
+
|
|
760
|
+
await handler.handleCommand('/review', 'edit', adminPhone, reply);
|
|
761
|
+
|
|
762
|
+
expect(replies).toHaveLength(1);
|
|
763
|
+
expect(replies[0]).toContain('provide an answer');
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe('/review skip', () => {
|
|
768
|
+
it('marks as dismissed', async () => {
|
|
769
|
+
const q = createTestQuery({ id: 'q1', query: 'Irrelevant question' });
|
|
770
|
+
await store.addQuery(q);
|
|
771
|
+
|
|
772
|
+
await handler.handleCommand('/review', 'next', adminPhone, reply);
|
|
773
|
+
replies = [];
|
|
774
|
+
|
|
775
|
+
await handler.handleCommand('/review', 'skip', adminPhone, reply);
|
|
776
|
+
|
|
777
|
+
expect(replies).toHaveLength(1);
|
|
778
|
+
expect(replies[0]).toContain('Skipped');
|
|
779
|
+
expect(replies[0]).toContain('Irrelevant question');
|
|
780
|
+
|
|
781
|
+
const updated = await store.getQuery('q1');
|
|
782
|
+
expect(updated!.status).toBe('dismissed');
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
describe('/review stats', () => {
|
|
787
|
+
it('shows impact metrics', async () => {
|
|
788
|
+
await store.addQuery(createTestQuery({
|
|
789
|
+
id: 'q1', status: 'pending', timesAsked: 5,
|
|
790
|
+
query: 'return policy', uniqueCustomers: ['+111', '+222'],
|
|
791
|
+
}));
|
|
792
|
+
await store.addQuery(createTestQuery({
|
|
793
|
+
id: 'q2', status: 'taught', timesAsked: 3,
|
|
794
|
+
query: 'shipping', uniqueCustomers: ['+333'],
|
|
795
|
+
}));
|
|
796
|
+
|
|
797
|
+
await handler.handleCommand('/review', 'stats', adminPhone, reply);
|
|
798
|
+
|
|
799
|
+
expect(replies).toHaveLength(1);
|
|
800
|
+
const text = replies[0];
|
|
801
|
+
expect(text).toContain('Pending');
|
|
802
|
+
expect(text).toContain('1');
|
|
803
|
+
expect(text).toContain('Taught');
|
|
804
|
+
expect(text).toContain('Customers affected');
|
|
805
|
+
expect(text).toContain('Total times asked');
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
describe('/review help', () => {
|
|
810
|
+
it('shows help text', async () => {
|
|
811
|
+
await handler.handleCommand('/review', 'help', adminPhone, reply);
|
|
812
|
+
|
|
813
|
+
expect(replies).toHaveLength(1);
|
|
814
|
+
const text = replies[0];
|
|
815
|
+
expect(text).toContain('Review Commands');
|
|
816
|
+
expect(text).toContain('/review accept');
|
|
817
|
+
expect(text).toContain('/review edit');
|
|
818
|
+
expect(text).toContain('/review skip');
|
|
819
|
+
expect(text).toContain('/review stats');
|
|
820
|
+
expect(text).toContain('/review help');
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe('no current query', () => {
|
|
825
|
+
it('shows "No query selected" for accept', async () => {
|
|
826
|
+
await handler.handleCommand('/review', 'accept some answer', adminPhone, reply);
|
|
827
|
+
|
|
828
|
+
expect(replies).toHaveLength(1);
|
|
829
|
+
expect(replies[0]).toContain('No query selected');
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('shows "No query selected" for edit', async () => {
|
|
833
|
+
await handler.handleCommand('/review', 'edit some answer', adminPhone, reply);
|
|
834
|
+
|
|
835
|
+
expect(replies).toHaveLength(1);
|
|
836
|
+
expect(replies[0]).toContain('No query selected');
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('shows "No query selected" for skip', async () => {
|
|
840
|
+
await handler.handleCommand('/review', 'skip', adminPhone, reply);
|
|
841
|
+
|
|
842
|
+
expect(replies).toHaveLength(1);
|
|
843
|
+
expect(replies[0]).toContain('No query selected');
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('handles unknown subcommand', async () => {
|
|
848
|
+
await handler.handleCommand('/review', 'foobar', adminPhone, reply);
|
|
849
|
+
|
|
850
|
+
expect(replies).toHaveLength(1);
|
|
851
|
+
expect(replies[0]).toContain('Unknown subcommand');
|
|
852
|
+
expect(replies[0]).toContain('foobar');
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('maintains separate sessions per admin', async () => {
|
|
856
|
+
const q1 = createTestQuery({ id: 'q1', query: 'Q1', timesAsked: 10 });
|
|
857
|
+
const q2 = createTestQuery({ id: 'q2', query: 'Q2', timesAsked: 5 });
|
|
858
|
+
await store.addQuery(q1);
|
|
859
|
+
await store.addQuery(q2);
|
|
860
|
+
|
|
861
|
+
const replies1: string[] = [];
|
|
862
|
+
const replies2: string[] = [];
|
|
863
|
+
const reply1 = async (t: string) => { replies1.push(t); };
|
|
864
|
+
const reply2 = async (t: string) => { replies2.push(t); };
|
|
865
|
+
|
|
866
|
+
// Admin1 loads next query (q1 has highest timesAsked)
|
|
867
|
+
await handler.handleCommand('/review', 'next', '+admin1', reply1);
|
|
868
|
+
expect(replies1[0]).toContain('Q1');
|
|
869
|
+
|
|
870
|
+
// Admin2 also loads the top query independently
|
|
871
|
+
await handler.handleCommand('/review', 'next', '+admin2', reply2);
|
|
872
|
+
expect(replies2[0]).toContain('Q1');
|
|
873
|
+
|
|
874
|
+
// Admin1 accepts -- should not affect admin2's session
|
|
875
|
+
await handler.handleCommand('/review', 'accept The answer is X', '+admin1', reply1);
|
|
876
|
+
expect(replies1[1]).toContain('Taught');
|
|
877
|
+
|
|
878
|
+
// Admin2 can still skip their own loaded query
|
|
879
|
+
await handler.handleCommand('/review', 'skip', '+admin2', reply2);
|
|
880
|
+
expect(replies2[1]).toContain('Skipped');
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// 5. DigestScheduler
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
describe('DigestScheduler', () => {
|
|
889
|
+
let store: InMemoryCopilotStore;
|
|
890
|
+
|
|
891
|
+
beforeEach(() => {
|
|
892
|
+
store = new InMemoryCopilotStore();
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('buildDigest returns null when no pending queries', async () => {
|
|
896
|
+
const scheduler = new DigestScheduler(
|
|
897
|
+
store,
|
|
898
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 10 },
|
|
899
|
+
vi.fn(),
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
const digest = await scheduler.buildDigest();
|
|
903
|
+
expect(digest).toBeNull();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('buildDigest returns null when all queries are taught/dismissed', async () => {
|
|
907
|
+
await store.addQuery(createTestQuery({ id: 'q1', status: 'taught' }));
|
|
908
|
+
await store.addQuery(createTestQuery({ id: 'q2', status: 'dismissed' }));
|
|
909
|
+
|
|
910
|
+
const scheduler = new DigestScheduler(
|
|
911
|
+
store,
|
|
912
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 10 },
|
|
913
|
+
vi.fn(),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
const digest = await scheduler.buildDigest();
|
|
917
|
+
expect(digest).toBeNull();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('buildDigest formats digest text correctly', async () => {
|
|
921
|
+
await store.addQuery(createTestQuery({
|
|
922
|
+
id: 'q1', status: 'pending', timesAsked: 5,
|
|
923
|
+
query: 'Return policy?', uniqueCustomers: ['+111', '+222'],
|
|
924
|
+
}));
|
|
925
|
+
await store.addQuery(createTestQuery({
|
|
926
|
+
id: 'q2', status: 'pending', timesAsked: 3,
|
|
927
|
+
query: 'Shipping cost?', uniqueCustomers: ['+333'],
|
|
928
|
+
}));
|
|
929
|
+
|
|
930
|
+
const scheduler = new DigestScheduler(
|
|
931
|
+
store,
|
|
932
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 10 },
|
|
933
|
+
vi.fn(),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
const digest = await scheduler.buildDigest();
|
|
937
|
+
expect(digest).not.toBeNull();
|
|
938
|
+
expect(digest).toContain('Training Copilot Digest');
|
|
939
|
+
expect(digest).toContain('2'); // pendingCount
|
|
940
|
+
expect(digest).toContain('3'); // totalCustomersAffected
|
|
941
|
+
expect(digest).toContain('/review');
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('buildDigest includes top queries', async () => {
|
|
945
|
+
await store.addQuery(createTestQuery({
|
|
946
|
+
id: 'q1', status: 'pending', timesAsked: 10,
|
|
947
|
+
query: 'Most asked question?', uniqueCustomers: ['+111'],
|
|
948
|
+
}));
|
|
949
|
+
await store.addQuery(createTestQuery({
|
|
950
|
+
id: 'q2', status: 'pending', timesAsked: 3,
|
|
951
|
+
query: 'Less asked question?', uniqueCustomers: ['+222'],
|
|
952
|
+
}));
|
|
953
|
+
|
|
954
|
+
const scheduler = new DigestScheduler(
|
|
955
|
+
store,
|
|
956
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 10 },
|
|
957
|
+
vi.fn(),
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
const digest = await scheduler.buildDigest();
|
|
961
|
+
expect(digest).toContain('Most asked question?');
|
|
962
|
+
expect(digest).toContain('Less asked question?');
|
|
963
|
+
expect(digest).toContain('10'); // timesAsked for top query
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('buildDigest respects digestMaxItems', async () => {
|
|
967
|
+
for (let i = 0; i < 5; i++) {
|
|
968
|
+
await store.addQuery(createTestQuery({
|
|
969
|
+
id: `q${i}`,
|
|
970
|
+
status: 'pending',
|
|
971
|
+
timesAsked: (5 - i),
|
|
972
|
+
query: `Question ${i}?`,
|
|
973
|
+
uniqueCustomers: [`+${i}`],
|
|
974
|
+
}));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const scheduler = new DigestScheduler(
|
|
978
|
+
store,
|
|
979
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 2 },
|
|
980
|
+
vi.fn(),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
const digest = await scheduler.buildDigest();
|
|
984
|
+
expect(digest).not.toBeNull();
|
|
985
|
+
// Should include the top 2 (Question 0 with 5 asks, Question 1 with 4 asks)
|
|
986
|
+
expect(digest).toContain('Question 0?');
|
|
987
|
+
expect(digest).toContain('Question 1?');
|
|
988
|
+
// Should NOT include Question 4 (lowest timesAsked)
|
|
989
|
+
expect(digest).not.toContain('Question 4?');
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('start/stop manages the interval timer', () => {
|
|
993
|
+
const sendMessage = vi.fn();
|
|
994
|
+
const scheduler = new DigestScheduler(
|
|
995
|
+
store,
|
|
996
|
+
{ digestIntervalMs: 86_400_000, digestMaxItems: 10 },
|
|
997
|
+
sendMessage,
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
// start creates a timer, stop clears it -- no assertions needed beyond
|
|
1001
|
+
// verifying it does not throw
|
|
1002
|
+
scheduler.start(['+admin1']);
|
|
1003
|
+
scheduler.stop();
|
|
1004
|
+
// Double stop should be safe
|
|
1005
|
+
scheduler.stop();
|
|
1006
|
+
});
|
|
1007
|
+
});
|