@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
//#region src/SQLiteAnalyticsStore.ts
|
|
4
|
+
const SESSION_GAP_MS$1 = 1800 * 1e3;
|
|
5
|
+
var SQLiteAnalyticsStore = class {
|
|
6
|
+
db;
|
|
7
|
+
constructor(dbPath = "./analytics.db") {
|
|
8
|
+
this.db = new Database(dbPath);
|
|
9
|
+
this.db.pragma("journal_mode = WAL");
|
|
10
|
+
this.db.pragma("foreign_keys = ON");
|
|
11
|
+
}
|
|
12
|
+
async initialize() {
|
|
13
|
+
this.db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS message_events (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
timestamp INTEGER NOT NULL,
|
|
17
|
+
channel TEXT NOT NULL,
|
|
18
|
+
customer_phone TEXT NOT NULL,
|
|
19
|
+
customer_id TEXT NOT NULL,
|
|
20
|
+
agent_name TEXT NOT NULL,
|
|
21
|
+
intent TEXT NOT NULL DEFAULT '',
|
|
22
|
+
intent_confidence REAL NOT NULL DEFAULT 0,
|
|
23
|
+
response_time_ms INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
llm_cost REAL NOT NULL DEFAULT 0,
|
|
25
|
+
kb_top_score REAL NOT NULL DEFAULT 0,
|
|
26
|
+
kb_is_faq_match INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
kb_result_count INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
tool_calls_count INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
tool_calls_json TEXT,
|
|
30
|
+
message_length INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
response_length INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
is_new_customer INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
was_escalated INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
conversation_id TEXT,
|
|
35
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON message_events(timestamp);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_events_agent ON message_events(agent_name);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_events_customer ON message_events(customer_phone);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_events_conversation ON message_events(conversation_id);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
customer_id TEXT NOT NULL,
|
|
47
|
+
customer_phone TEXT NOT NULL,
|
|
48
|
+
channel TEXT NOT NULL,
|
|
49
|
+
agent_id TEXT NOT NULL,
|
|
50
|
+
started_at INTEGER NOT NULL,
|
|
51
|
+
ended_at INTEGER NOT NULL,
|
|
52
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
tool_call_count INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
total_duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
total_cost REAL NOT NULL DEFAULT 0,
|
|
56
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
57
|
+
resolution_type TEXT
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS daily_metrics (
|
|
61
|
+
date TEXT NOT NULL,
|
|
62
|
+
metric TEXT NOT NULL,
|
|
63
|
+
channel TEXT NOT NULL DEFAULT '',
|
|
64
|
+
agent_id TEXT NOT NULL DEFAULT '',
|
|
65
|
+
value REAL NOT NULL DEFAULT 0,
|
|
66
|
+
PRIMARY KEY (date, metric, channel, agent_id)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS analytics_meta (
|
|
70
|
+
key TEXT PRIMARY KEY,
|
|
71
|
+
value TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
async close() {
|
|
76
|
+
this.db.close();
|
|
77
|
+
}
|
|
78
|
+
async recordMessageEvent(event) {
|
|
79
|
+
this.db.prepare(`
|
|
80
|
+
INSERT INTO message_events
|
|
81
|
+
(timestamp, channel, customer_phone, customer_id, agent_name, intent,
|
|
82
|
+
intent_confidence, response_time_ms, llm_cost, kb_top_score, kb_is_faq_match,
|
|
83
|
+
kb_result_count, tool_calls_count, tool_calls_json, message_length,
|
|
84
|
+
response_length, is_new_customer, was_escalated, conversation_id,
|
|
85
|
+
prompt_tokens, completion_tokens)
|
|
86
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
`).run(event.timestamp, event.channel, event.customerPhone, event.customerId, event.agentName, event.intent, event.intentConfidence, event.responseTimeMs, event.llmCost, event.kbTopScore, event.kbIsFaqMatch ? 1 : 0, event.kbResultCount, event.toolCallsCount, event.toolCallsJson, event.messageLength, event.responseLength, event.isNewCustomer ? 1 : 0, event.wasEscalated ? 1 : 0, event.conversationId, event.promptTokens, event.completionTokens);
|
|
88
|
+
}
|
|
89
|
+
async getOrCreateConversation(customerPhone, channel, agentName) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const cutoff = now - SESSION_GAP_MS$1;
|
|
92
|
+
const existing = this.db.prepare(`
|
|
93
|
+
SELECT id FROM conversations
|
|
94
|
+
WHERE customer_phone = ? AND channel = ? AND agent_id = ? AND ended_at > ?
|
|
95
|
+
ORDER BY ended_at DESC LIMIT 1
|
|
96
|
+
`).get(customerPhone, channel, agentName, cutoff);
|
|
97
|
+
if (existing) return existing.id;
|
|
98
|
+
const id = crypto.randomUUID();
|
|
99
|
+
this.db.prepare(`
|
|
100
|
+
INSERT INTO conversations (id, customer_id, customer_phone, channel, agent_id, started_at, ended_at)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
102
|
+
`).run(id, "", customerPhone, channel, agentName, now, now);
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
async updateConversationStats(conversationId, event) {
|
|
106
|
+
this.db.prepare(`
|
|
107
|
+
UPDATE conversations SET
|
|
108
|
+
customer_id = CASE WHEN customer_id = '' THEN ? ELSE customer_id END,
|
|
109
|
+
ended_at = ?,
|
|
110
|
+
message_count = message_count + 1,
|
|
111
|
+
tool_call_count = tool_call_count + ?,
|
|
112
|
+
total_duration_ms = ? - started_at,
|
|
113
|
+
total_cost = total_cost + ?
|
|
114
|
+
WHERE id = ?
|
|
115
|
+
`).run(event.customerId, event.timestamp, event.toolCallsCount, event.timestamp, event.llmCost, conversationId);
|
|
116
|
+
}
|
|
117
|
+
async getSummary(range) {
|
|
118
|
+
const where = range ? "WHERE timestamp >= ? AND timestamp <= ?" : "";
|
|
119
|
+
const params = range ? [range.from, range.to] : [];
|
|
120
|
+
const row = this.db.prepare(`
|
|
121
|
+
SELECT
|
|
122
|
+
COUNT(*) as total,
|
|
123
|
+
AVG(response_time_ms) as avg_response_time,
|
|
124
|
+
COUNT(DISTINCT customer_phone) as unique_customers,
|
|
125
|
+
SUM(CASE WHEN is_new_customer = 1 THEN 1 ELSE 0 END) as new_customers,
|
|
126
|
+
SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,
|
|
127
|
+
SUM(CASE WHEN kb_top_score >= 0.7 THEN 1 ELSE 0 END) as kb_answered,
|
|
128
|
+
SUM(CASE WHEN kb_top_score < 0.3 THEN 1 ELSE 0 END) as no_answer
|
|
129
|
+
FROM message_events ${where}
|
|
130
|
+
`).get(...params);
|
|
131
|
+
const total = row.total || 0;
|
|
132
|
+
const peakRow = this.db.prepare(`
|
|
133
|
+
SELECT date(timestamp / 1000, 'unixepoch') as day, COUNT(*) as cnt
|
|
134
|
+
FROM message_events ${where}
|
|
135
|
+
GROUP BY day ORDER BY cnt DESC LIMIT 1
|
|
136
|
+
`).get(...params);
|
|
137
|
+
const agentRows = this.db.prepare(`
|
|
138
|
+
SELECT agent_name, COUNT(*) as cnt
|
|
139
|
+
FROM message_events ${where}
|
|
140
|
+
GROUP BY agent_name
|
|
141
|
+
`).all(...params);
|
|
142
|
+
const agentBreakdown = {};
|
|
143
|
+
for (const r of agentRows) agentBreakdown[r.agent_name] = r.cnt;
|
|
144
|
+
return {
|
|
145
|
+
totalMessages: total,
|
|
146
|
+
avgPerDay: total / (this.db.prepare(`
|
|
147
|
+
SELECT COUNT(DISTINCT date(timestamp / 1000, 'unixepoch')) as days
|
|
148
|
+
FROM message_events ${where}
|
|
149
|
+
`).get(...params).days || 1),
|
|
150
|
+
peakDay: peakRow?.day ?? null,
|
|
151
|
+
kbAnsweredPct: total > 0 ? row.kb_answered / total * 100 : 0,
|
|
152
|
+
llmFallbackPct: total > 0 ? (total - row.kb_answered) / total * 100 : 0,
|
|
153
|
+
noAnswerPct: total > 0 ? row.no_answer / total * 100 : 0,
|
|
154
|
+
avgResponseTime: row.avg_response_time || 0,
|
|
155
|
+
faqHitRate: total > 0 ? row.faq_hits / total * 100 : 0,
|
|
156
|
+
uniqueCustomers: row.unique_customers || 0,
|
|
157
|
+
newCustomers: row.new_customers || 0,
|
|
158
|
+
returningCustomers: (row.unique_customers || 0) - (row.new_customers || 0),
|
|
159
|
+
agentBreakdown,
|
|
160
|
+
pendingReviews: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async getMessageVolume(range, groupBy = "day") {
|
|
164
|
+
const where = range ? "WHERE timestamp >= ? AND timestamp <= ?" : "";
|
|
165
|
+
const params = range ? [range.from, range.to] : [];
|
|
166
|
+
const dateExpr = groupBy === "hour" ? "strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch')" : "date(timestamp / 1000, 'unixepoch')";
|
|
167
|
+
return this.db.prepare(`
|
|
168
|
+
SELECT ${dateExpr} as date, COUNT(*) as count
|
|
169
|
+
FROM message_events ${where}
|
|
170
|
+
GROUP BY date ORDER BY date
|
|
171
|
+
`).all(...params).map((r) => ({
|
|
172
|
+
date: r.date,
|
|
173
|
+
count: r.count
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
async getAgentStats(range) {
|
|
177
|
+
const where = range ? "WHERE timestamp >= ? AND timestamp <= ?" : "";
|
|
178
|
+
const params = range ? [range.from, range.to] : [];
|
|
179
|
+
return this.db.prepare(`
|
|
180
|
+
SELECT
|
|
181
|
+
agent_name,
|
|
182
|
+
COUNT(*) as message_count,
|
|
183
|
+
AVG(response_time_ms) as avg_response_time,
|
|
184
|
+
AVG(intent_confidence) as avg_confidence,
|
|
185
|
+
SUM(CASE WHEN was_escalated = 1 THEN 1 ELSE 0 END) as escalation_count,
|
|
186
|
+
SUM(tool_calls_count) as tool_call_count
|
|
187
|
+
FROM message_events ${where}
|
|
188
|
+
GROUP BY agent_name
|
|
189
|
+
`).all(...params).map((r) => ({
|
|
190
|
+
agentName: r.agent_name,
|
|
191
|
+
messageCount: r.message_count,
|
|
192
|
+
avgResponseTimeMs: r.avg_response_time || 0,
|
|
193
|
+
avgConfidence: r.avg_confidence || 0,
|
|
194
|
+
escalationCount: r.escalation_count || 0,
|
|
195
|
+
toolCallCount: r.tool_call_count || 0
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
async getCustomerStats(range, limit = 50) {
|
|
199
|
+
const where = range ? "WHERE timestamp >= ? AND timestamp <= ?" : "";
|
|
200
|
+
const params = range ? [
|
|
201
|
+
range.from,
|
|
202
|
+
range.to,
|
|
203
|
+
limit
|
|
204
|
+
] : [limit];
|
|
205
|
+
return this.db.prepare(`
|
|
206
|
+
SELECT
|
|
207
|
+
customer_id,
|
|
208
|
+
customer_phone,
|
|
209
|
+
COUNT(*) as message_count,
|
|
210
|
+
MIN(timestamp) as first_seen,
|
|
211
|
+
MAX(timestamp) as last_seen,
|
|
212
|
+
MAX(is_new_customer) as is_new
|
|
213
|
+
FROM message_events ${where}
|
|
214
|
+
GROUP BY customer_phone
|
|
215
|
+
ORDER BY message_count DESC
|
|
216
|
+
LIMIT ?
|
|
217
|
+
`).all(...params).map((r) => ({
|
|
218
|
+
customerId: r.customer_id,
|
|
219
|
+
customerPhone: r.customer_phone,
|
|
220
|
+
messageCount: r.message_count,
|
|
221
|
+
firstSeen: r.first_seen,
|
|
222
|
+
lastSeen: r.last_seen,
|
|
223
|
+
isNew: !!r.is_new
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
async getKbStats(range) {
|
|
227
|
+
const where = range ? "WHERE timestamp >= ? AND timestamp <= ?" : "";
|
|
228
|
+
const params = range ? [range.from, range.to] : [];
|
|
229
|
+
const row = this.db.prepare(`
|
|
230
|
+
SELECT
|
|
231
|
+
COUNT(*) as total,
|
|
232
|
+
SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,
|
|
233
|
+
AVG(kb_top_score) as avg_score
|
|
234
|
+
FROM message_events ${where}
|
|
235
|
+
`).get(...params);
|
|
236
|
+
const total = row.total || 0;
|
|
237
|
+
const topHits = await this.getTopFaqHits(10, range);
|
|
238
|
+
const topMisses = await this.getTopMisses(10, range);
|
|
239
|
+
return {
|
|
240
|
+
totalQueries: total,
|
|
241
|
+
faqHits: row.faq_hits || 0,
|
|
242
|
+
faqHitRate: total > 0 ? (row.faq_hits || 0) / total * 100 : 0,
|
|
243
|
+
avgTopScore: row.avg_score || 0,
|
|
244
|
+
topFaqHits: topHits,
|
|
245
|
+
topMisses
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async getTopFaqHits(limit = 10, range) {
|
|
249
|
+
const where = range ? "WHERE kb_is_faq_match = 1 AND timestamp >= ? AND timestamp <= ?" : "WHERE kb_is_faq_match = 1";
|
|
250
|
+
const params = range ? [
|
|
251
|
+
range.from,
|
|
252
|
+
range.to,
|
|
253
|
+
limit
|
|
254
|
+
] : [limit];
|
|
255
|
+
return this.db.prepare(`
|
|
256
|
+
SELECT intent, COUNT(*) as count
|
|
257
|
+
FROM message_events ${where}
|
|
258
|
+
GROUP BY intent ORDER BY count DESC LIMIT ?
|
|
259
|
+
`).all(...params).map((r) => ({
|
|
260
|
+
intent: r.intent,
|
|
261
|
+
count: r.count
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
async getTopMisses(limit = 10, range) {
|
|
265
|
+
const where = range ? "WHERE kb_top_score < 0.5 AND kb_top_score > 0 AND timestamp >= ? AND timestamp <= ?" : "WHERE kb_top_score < 0.5 AND kb_top_score > 0";
|
|
266
|
+
const params = range ? [
|
|
267
|
+
range.from,
|
|
268
|
+
range.to,
|
|
269
|
+
limit
|
|
270
|
+
] : [limit];
|
|
271
|
+
return this.db.prepare(`
|
|
272
|
+
SELECT intent, COUNT(*) as count, AVG(kb_top_score) as avg_score
|
|
273
|
+
FROM message_events ${where}
|
|
274
|
+
GROUP BY intent ORDER BY count DESC LIMIT ?
|
|
275
|
+
`).all(...params).map((r) => ({
|
|
276
|
+
intent: r.intent,
|
|
277
|
+
count: r.count,
|
|
278
|
+
avgScore: r.avg_score
|
|
279
|
+
}));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
//#endregion
|
|
284
|
+
//#region src/InMemoryAnalyticsStore.ts
|
|
285
|
+
const SESSION_GAP_MS = 1800 * 1e3;
|
|
286
|
+
/**
|
|
287
|
+
* In-memory analytics store for testing and development.
|
|
288
|
+
*/
|
|
289
|
+
var InMemoryAnalyticsStore = class {
|
|
290
|
+
events = [];
|
|
291
|
+
conversations = /* @__PURE__ */ new Map();
|
|
292
|
+
async initialize() {}
|
|
293
|
+
async close() {}
|
|
294
|
+
async recordMessageEvent(event) {
|
|
295
|
+
this.events.push({ ...event });
|
|
296
|
+
}
|
|
297
|
+
async getOrCreateConversation(customerPhone, channel, agentName) {
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
const cutoff = now - SESSION_GAP_MS;
|
|
300
|
+
for (const conv of this.conversations.values()) if (conv.customerPhone === customerPhone && conv.channel === channel && conv.agentId === agentName && conv.endedAt > cutoff) return conv.id;
|
|
301
|
+
const id = crypto.randomUUID();
|
|
302
|
+
this.conversations.set(id, {
|
|
303
|
+
id,
|
|
304
|
+
customerId: "",
|
|
305
|
+
customerPhone,
|
|
306
|
+
channel,
|
|
307
|
+
agentId: agentName,
|
|
308
|
+
startedAt: now,
|
|
309
|
+
endedAt: now,
|
|
310
|
+
messageCount: 0,
|
|
311
|
+
toolCallCount: 0,
|
|
312
|
+
totalCost: 0
|
|
313
|
+
});
|
|
314
|
+
return id;
|
|
315
|
+
}
|
|
316
|
+
async updateConversationStats(conversationId, event) {
|
|
317
|
+
const conv = this.conversations.get(conversationId);
|
|
318
|
+
if (!conv) return;
|
|
319
|
+
if (!conv.customerId) conv.customerId = event.customerId;
|
|
320
|
+
conv.endedAt = event.timestamp;
|
|
321
|
+
conv.messageCount += 1;
|
|
322
|
+
conv.toolCallCount += event.toolCallsCount;
|
|
323
|
+
conv.totalCost += event.llmCost;
|
|
324
|
+
}
|
|
325
|
+
filter(range) {
|
|
326
|
+
if (!range) return this.events;
|
|
327
|
+
return this.events.filter((e) => e.timestamp >= range.from && e.timestamp <= range.to);
|
|
328
|
+
}
|
|
329
|
+
async getSummary(range) {
|
|
330
|
+
const events = this.filter(range);
|
|
331
|
+
const total = events.length;
|
|
332
|
+
if (total === 0) return {
|
|
333
|
+
totalMessages: 0,
|
|
334
|
+
avgPerDay: 0,
|
|
335
|
+
peakDay: null,
|
|
336
|
+
kbAnsweredPct: 0,
|
|
337
|
+
llmFallbackPct: 0,
|
|
338
|
+
noAnswerPct: 0,
|
|
339
|
+
avgResponseTime: 0,
|
|
340
|
+
faqHitRate: 0,
|
|
341
|
+
uniqueCustomers: 0,
|
|
342
|
+
newCustomers: 0,
|
|
343
|
+
returningCustomers: 0,
|
|
344
|
+
agentBreakdown: {},
|
|
345
|
+
pendingReviews: 0
|
|
346
|
+
};
|
|
347
|
+
const customers = new Set(events.map((e) => e.customerPhone));
|
|
348
|
+
const newCust = events.filter((e) => e.isNewCustomer).length;
|
|
349
|
+
const faqHits = events.filter((e) => e.kbIsFaqMatch).length;
|
|
350
|
+
const kbAnswered = events.filter((e) => e.kbTopScore >= .7).length;
|
|
351
|
+
const noAnswer = events.filter((e) => e.kbTopScore < .3).length;
|
|
352
|
+
const avgResp = events.reduce((s, e) => s + e.responseTimeMs, 0) / total;
|
|
353
|
+
const dayCounts = /* @__PURE__ */ new Map();
|
|
354
|
+
for (const e of events) {
|
|
355
|
+
const day = new Date(e.timestamp).toISOString().slice(0, 10);
|
|
356
|
+
dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
|
|
357
|
+
}
|
|
358
|
+
let peakDay = null;
|
|
359
|
+
let peakCount = 0;
|
|
360
|
+
for (const [day, cnt] of dayCounts) if (cnt > peakCount) {
|
|
361
|
+
peakDay = day;
|
|
362
|
+
peakCount = cnt;
|
|
363
|
+
}
|
|
364
|
+
const agentBreakdown = {};
|
|
365
|
+
for (const e of events) agentBreakdown[e.agentName] = (agentBreakdown[e.agentName] || 0) + 1;
|
|
366
|
+
return {
|
|
367
|
+
totalMessages: total,
|
|
368
|
+
avgPerDay: total / (dayCounts.size || 1),
|
|
369
|
+
peakDay,
|
|
370
|
+
kbAnsweredPct: kbAnswered / total * 100,
|
|
371
|
+
llmFallbackPct: (total - kbAnswered) / total * 100,
|
|
372
|
+
noAnswerPct: noAnswer / total * 100,
|
|
373
|
+
avgResponseTime: avgResp,
|
|
374
|
+
faqHitRate: faqHits / total * 100,
|
|
375
|
+
uniqueCustomers: customers.size,
|
|
376
|
+
newCustomers: newCust,
|
|
377
|
+
returningCustomers: customers.size - newCust,
|
|
378
|
+
agentBreakdown,
|
|
379
|
+
pendingReviews: 0
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async getMessageVolume(range, groupBy = "day") {
|
|
383
|
+
const events = this.filter(range);
|
|
384
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
385
|
+
for (const e of events) {
|
|
386
|
+
const d = new Date(e.timestamp);
|
|
387
|
+
const key = groupBy === "hour" ? d.toISOString().slice(0, 13) + ":00" : d.toISOString().slice(0, 10);
|
|
388
|
+
buckets.set(key, (buckets.get(key) || 0) + 1);
|
|
389
|
+
}
|
|
390
|
+
return [...buckets.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, count]) => ({
|
|
391
|
+
date,
|
|
392
|
+
count
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
async getAgentStats(range) {
|
|
396
|
+
const events = this.filter(range);
|
|
397
|
+
const map = /* @__PURE__ */ new Map();
|
|
398
|
+
for (const e of events) {
|
|
399
|
+
const s = map.get(e.agentName) || {
|
|
400
|
+
count: 0,
|
|
401
|
+
respTime: 0,
|
|
402
|
+
conf: 0,
|
|
403
|
+
esc: 0,
|
|
404
|
+
tools: 0
|
|
405
|
+
};
|
|
406
|
+
s.count++;
|
|
407
|
+
s.respTime += e.responseTimeMs;
|
|
408
|
+
s.conf += e.intentConfidence;
|
|
409
|
+
s.esc += e.wasEscalated ? 1 : 0;
|
|
410
|
+
s.tools += e.toolCallsCount;
|
|
411
|
+
map.set(e.agentName, s);
|
|
412
|
+
}
|
|
413
|
+
return [...map.entries()].map(([name, s]) => ({
|
|
414
|
+
agentName: name,
|
|
415
|
+
messageCount: s.count,
|
|
416
|
+
avgResponseTimeMs: s.count > 0 ? s.respTime / s.count : 0,
|
|
417
|
+
avgConfidence: s.count > 0 ? s.conf / s.count : 0,
|
|
418
|
+
escalationCount: s.esc,
|
|
419
|
+
toolCallCount: s.tools
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
async getCustomerStats(range, limit = 50) {
|
|
423
|
+
const events = this.filter(range);
|
|
424
|
+
const map = /* @__PURE__ */ new Map();
|
|
425
|
+
for (const e of events) {
|
|
426
|
+
const s = map.get(e.customerPhone) || {
|
|
427
|
+
id: e.customerId,
|
|
428
|
+
count: 0,
|
|
429
|
+
first: e.timestamp,
|
|
430
|
+
last: e.timestamp,
|
|
431
|
+
isNew: false
|
|
432
|
+
};
|
|
433
|
+
s.count++;
|
|
434
|
+
if (e.timestamp < s.first) s.first = e.timestamp;
|
|
435
|
+
if (e.timestamp > s.last) s.last = e.timestamp;
|
|
436
|
+
if (e.isNewCustomer) s.isNew = true;
|
|
437
|
+
map.set(e.customerPhone, s);
|
|
438
|
+
}
|
|
439
|
+
return [...map.entries()].sort(([, a], [, b]) => b.count - a.count).slice(0, limit).map(([phone, s]) => ({
|
|
440
|
+
customerId: s.id,
|
|
441
|
+
customerPhone: phone,
|
|
442
|
+
messageCount: s.count,
|
|
443
|
+
firstSeen: s.first,
|
|
444
|
+
lastSeen: s.last,
|
|
445
|
+
isNew: s.isNew
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
async getKbStats(range) {
|
|
449
|
+
const events = this.filter(range);
|
|
450
|
+
const total = events.length;
|
|
451
|
+
const faqHits = events.filter((e) => e.kbIsFaqMatch).length;
|
|
452
|
+
const avgScore = total > 0 ? events.reduce((s, e) => s + e.kbTopScore, 0) / total : 0;
|
|
453
|
+
return {
|
|
454
|
+
totalQueries: total,
|
|
455
|
+
faqHits,
|
|
456
|
+
faqHitRate: total > 0 ? faqHits / total * 100 : 0,
|
|
457
|
+
avgTopScore: avgScore,
|
|
458
|
+
topFaqHits: await this.getTopFaqHits(10, range),
|
|
459
|
+
topMisses: await this.getTopMisses(10, range)
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
async getTopFaqHits(limit = 10, range) {
|
|
463
|
+
const events = this.filter(range).filter((e) => e.kbIsFaqMatch);
|
|
464
|
+
const map = /* @__PURE__ */ new Map();
|
|
465
|
+
for (const e of events) map.set(e.intent, (map.get(e.intent) || 0) + 1);
|
|
466
|
+
return [...map.entries()].sort(([, a], [, b]) => b - a).slice(0, limit).map(([intent, count]) => ({
|
|
467
|
+
intent,
|
|
468
|
+
count
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
async getTopMisses(limit = 10, range) {
|
|
472
|
+
const events = this.filter(range).filter((e) => e.kbTopScore < .5 && e.kbTopScore > 0);
|
|
473
|
+
const map = /* @__PURE__ */ new Map();
|
|
474
|
+
for (const e of events) {
|
|
475
|
+
const s = map.get(e.intent) || {
|
|
476
|
+
count: 0,
|
|
477
|
+
totalScore: 0
|
|
478
|
+
};
|
|
479
|
+
s.count++;
|
|
480
|
+
s.totalScore += e.kbTopScore;
|
|
481
|
+
map.set(e.intent, s);
|
|
482
|
+
}
|
|
483
|
+
return [...map.entries()].sort(([, a], [, b]) => b.count - a.count).slice(0, limit).map(([intent, s]) => ({
|
|
484
|
+
intent,
|
|
485
|
+
count: s.count,
|
|
486
|
+
avgScore: s.totalScore / s.count
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
//#endregion
|
|
492
|
+
//#region src/AnalyticsCollector.ts
|
|
493
|
+
/**
|
|
494
|
+
* Listens to Operor events and persists analytics data.
|
|
495
|
+
*/
|
|
496
|
+
var AnalyticsCollector = class {
|
|
497
|
+
store;
|
|
498
|
+
debug;
|
|
499
|
+
boundHandlers = /* @__PURE__ */ new Map();
|
|
500
|
+
constructor(store, options) {
|
|
501
|
+
this.store = store;
|
|
502
|
+
this.debug = options?.debug ?? false;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Attach to an Operor instance (or any EventEmitter with the same events).
|
|
506
|
+
*/
|
|
507
|
+
attach(os) {
|
|
508
|
+
const onProcessed = (payload) => {
|
|
509
|
+
this.handleProcessed(payload).catch((err) => {
|
|
510
|
+
if (this.debug) console.warn("[Analytics] Error recording event:", err?.message);
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
const onError = (payload) => {
|
|
514
|
+
this.handleError(payload).catch((err) => {
|
|
515
|
+
if (this.debug) console.warn("[Analytics] Error recording error event:", err?.message);
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
os.on("message:processed", onProcessed);
|
|
519
|
+
os.on("error", onError);
|
|
520
|
+
this.boundHandlers.set("message:processed", onProcessed);
|
|
521
|
+
this.boundHandlers.set("error", onError);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Detach from an Operor instance.
|
|
525
|
+
*/
|
|
526
|
+
detach(os) {
|
|
527
|
+
for (const [event, handler] of this.boundHandlers) os.off(event, handler);
|
|
528
|
+
this.boundHandlers.clear();
|
|
529
|
+
}
|
|
530
|
+
async handleProcessed(payload) {
|
|
531
|
+
const { message, response, agent, intent, customer } = payload;
|
|
532
|
+
const toolCalls = response.toolCalls || payload.toolCalls || [];
|
|
533
|
+
const meta = response.metadata || {};
|
|
534
|
+
const isNew = customer.firstInteraction ? Date.now() - customer.firstInteraction.getTime() < 6e4 : false;
|
|
535
|
+
const conversationId = await this.store.getOrCreateConversation(message.from, message.channel || message.provider, agent);
|
|
536
|
+
const event = {
|
|
537
|
+
timestamp: message.timestamp || Date.now(),
|
|
538
|
+
channel: message.channel || message.provider,
|
|
539
|
+
customerPhone: message.from,
|
|
540
|
+
customerId: customer.id,
|
|
541
|
+
agentName: agent,
|
|
542
|
+
intent: intent.intent,
|
|
543
|
+
intentConfidence: intent.confidence,
|
|
544
|
+
responseTimeMs: response.duration || payload.duration || 0,
|
|
545
|
+
llmCost: response.cost ?? payload.cost ?? 0,
|
|
546
|
+
kbTopScore: meta.kbTopScore ?? 0,
|
|
547
|
+
kbIsFaqMatch: meta.kbIsFaqMatch ?? false,
|
|
548
|
+
kbResultCount: meta.kbResultCount ?? 0,
|
|
549
|
+
toolCallsCount: toolCalls.length,
|
|
550
|
+
toolCallsJson: toolCalls.length > 0 ? JSON.stringify(toolCalls) : null,
|
|
551
|
+
messageLength: message.text?.length ?? 0,
|
|
552
|
+
responseLength: response.text?.length ?? 0,
|
|
553
|
+
isNewCustomer: isNew,
|
|
554
|
+
wasEscalated: false,
|
|
555
|
+
conversationId,
|
|
556
|
+
promptTokens: meta.promptTokens ?? 0,
|
|
557
|
+
completionTokens: meta.completionTokens ?? 0
|
|
558
|
+
};
|
|
559
|
+
await this.store.recordMessageEvent(event);
|
|
560
|
+
await this.store.updateConversationStats(conversationId, event);
|
|
561
|
+
if (this.debug) this.log(`Recorded event: agent=${agent} customer=${message.from} intent=${intent.intent}`);
|
|
562
|
+
}
|
|
563
|
+
async handleError(_payload) {}
|
|
564
|
+
log(msg) {
|
|
565
|
+
console.log(`[Analytics] ${msg}`);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
//#endregion
|
|
570
|
+
export { AnalyticsCollector, InMemoryAnalyticsStore, SQLiteAnalyticsStore };
|
|
571
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["SESSION_GAP_MS"],"sources":["../src/SQLiteAnalyticsStore.ts","../src/InMemoryAnalyticsStore.ts","../src/AnalyticsCollector.ts"],"sourcesContent":["import Database from 'better-sqlite3';\nimport type {\n AnalyticsStore,\n AnalyticsEvent,\n AnalyticsSummary,\n MessageVolumeData,\n AgentStatsData,\n CustomerStatsData,\n KbStatsData,\n ConversationSession,\n TimeRange,\n} from './types.js';\n\nconst SESSION_GAP_MS = 30 * 60 * 1000; // 30 minutes\n\nexport class SQLiteAnalyticsStore implements AnalyticsStore {\n private db: Database.Database;\n\n constructor(dbPath: string = './analytics.db') {\n this.db = new Database(dbPath);\n this.db.pragma('journal_mode = WAL');\n this.db.pragma('foreign_keys = ON');\n }\n\n async initialize(): Promise<void> {\n this.db.exec(`\n CREATE TABLE IF NOT EXISTS message_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n timestamp INTEGER NOT NULL,\n channel TEXT NOT NULL,\n customer_phone TEXT NOT NULL,\n customer_id TEXT NOT NULL,\n agent_name TEXT NOT NULL,\n intent TEXT NOT NULL DEFAULT '',\n intent_confidence REAL NOT NULL DEFAULT 0,\n response_time_ms INTEGER NOT NULL DEFAULT 0,\n llm_cost REAL NOT NULL DEFAULT 0,\n kb_top_score REAL NOT NULL DEFAULT 0,\n kb_is_faq_match INTEGER NOT NULL DEFAULT 0,\n kb_result_count INTEGER NOT NULL DEFAULT 0,\n tool_calls_count INTEGER NOT NULL DEFAULT 0,\n tool_calls_json TEXT,\n message_length INTEGER NOT NULL DEFAULT 0,\n response_length INTEGER NOT NULL DEFAULT 0,\n is_new_customer INTEGER NOT NULL DEFAULT 0,\n was_escalated INTEGER NOT NULL DEFAULT 0,\n conversation_id TEXT,\n prompt_tokens INTEGER NOT NULL DEFAULT 0,\n completion_tokens INTEGER NOT NULL DEFAULT 0\n );\n\n CREATE INDEX IF NOT EXISTS idx_events_timestamp ON message_events(timestamp);\n CREATE INDEX IF NOT EXISTS idx_events_agent ON message_events(agent_name);\n CREATE INDEX IF NOT EXISTS idx_events_customer ON message_events(customer_phone);\n CREATE INDEX IF NOT EXISTS idx_events_conversation ON message_events(conversation_id);\n\n CREATE TABLE IF NOT EXISTS conversations (\n id TEXT PRIMARY KEY,\n customer_id TEXT NOT NULL,\n customer_phone TEXT NOT NULL,\n channel TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n started_at INTEGER NOT NULL,\n ended_at INTEGER NOT NULL,\n message_count INTEGER NOT NULL DEFAULT 0,\n tool_call_count INTEGER NOT NULL DEFAULT 0,\n total_duration_ms INTEGER NOT NULL DEFAULT 0,\n total_cost REAL NOT NULL DEFAULT 0,\n resolved INTEGER NOT NULL DEFAULT 0,\n resolution_type TEXT\n );\n\n CREATE TABLE IF NOT EXISTS daily_metrics (\n date TEXT NOT NULL,\n metric TEXT NOT NULL,\n channel TEXT NOT NULL DEFAULT '',\n agent_id TEXT NOT NULL DEFAULT '',\n value REAL NOT NULL DEFAULT 0,\n PRIMARY KEY (date, metric, channel, agent_id)\n );\n\n CREATE TABLE IF NOT EXISTS analytics_meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n `);\n }\n\n async close(): Promise<void> {\n this.db.close();\n }\n\n async recordMessageEvent(event: AnalyticsEvent): Promise<void> {\n this.db.prepare(`\n INSERT INTO message_events\n (timestamp, channel, customer_phone, customer_id, agent_name, intent,\n intent_confidence, response_time_ms, llm_cost, kb_top_score, kb_is_faq_match,\n kb_result_count, tool_calls_count, tool_calls_json, message_length,\n response_length, is_new_customer, was_escalated, conversation_id,\n prompt_tokens, completion_tokens)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n event.timestamp,\n event.channel,\n event.customerPhone,\n event.customerId,\n event.agentName,\n event.intent,\n event.intentConfidence,\n event.responseTimeMs,\n event.llmCost,\n event.kbTopScore,\n event.kbIsFaqMatch ? 1 : 0,\n event.kbResultCount,\n event.toolCallsCount,\n event.toolCallsJson,\n event.messageLength,\n event.responseLength,\n event.isNewCustomer ? 1 : 0,\n event.wasEscalated ? 1 : 0,\n event.conversationId,\n event.promptTokens,\n event.completionTokens,\n );\n }\n\n async getOrCreateConversation(\n customerPhone: string,\n channel: string,\n agentName: string,\n ): Promise<string> {\n const now = Date.now();\n const cutoff = now - SESSION_GAP_MS;\n\n // Find an active conversation within the session gap\n const existing = this.db.prepare(`\n SELECT id FROM conversations\n WHERE customer_phone = ? AND channel = ? AND agent_id = ? AND ended_at > ?\n ORDER BY ended_at DESC LIMIT 1\n `).get(customerPhone, channel, agentName, cutoff) as any;\n\n if (existing) {\n return existing.id as string;\n }\n\n // Create new conversation\n const id = crypto.randomUUID();\n this.db.prepare(`\n INSERT INTO conversations (id, customer_id, customer_phone, channel, agent_id, started_at, ended_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)\n `).run(id, '', customerPhone, channel, agentName, now, now);\n\n return id;\n }\n\n async updateConversationStats(\n conversationId: string,\n event: AnalyticsEvent,\n ): Promise<void> {\n this.db.prepare(`\n UPDATE conversations SET\n customer_id = CASE WHEN customer_id = '' THEN ? ELSE customer_id END,\n ended_at = ?,\n message_count = message_count + 1,\n tool_call_count = tool_call_count + ?,\n total_duration_ms = ? - started_at,\n total_cost = total_cost + ?\n WHERE id = ?\n `).run(\n event.customerId,\n event.timestamp,\n event.toolCallsCount,\n event.timestamp,\n event.llmCost,\n conversationId,\n );\n }\n\n async getSummary(range?: TimeRange): Promise<AnalyticsSummary> {\n const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';\n const params = range ? [range.from, range.to] : [];\n\n const row = this.db.prepare(`\n SELECT\n COUNT(*) as total,\n AVG(response_time_ms) as avg_response_time,\n COUNT(DISTINCT customer_phone) as unique_customers,\n SUM(CASE WHEN is_new_customer = 1 THEN 1 ELSE 0 END) as new_customers,\n SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,\n SUM(CASE WHEN kb_top_score >= 0.7 THEN 1 ELSE 0 END) as kb_answered,\n SUM(CASE WHEN kb_top_score < 0.3 THEN 1 ELSE 0 END) as no_answer\n FROM message_events ${where}\n `).get(...params) as any;\n\n const total = row.total || 0;\n\n // Peak day\n const peakRow = this.db.prepare(`\n SELECT date(timestamp / 1000, 'unixepoch') as day, COUNT(*) as cnt\n FROM message_events ${where}\n GROUP BY day ORDER BY cnt DESC LIMIT 1\n `).get(...params) as any;\n\n // Agent breakdown\n const agentRows = this.db.prepare(`\n SELECT agent_name, COUNT(*) as cnt\n FROM message_events ${where}\n GROUP BY agent_name\n `).all(...params) as any[];\n\n const agentBreakdown: Record<string, number> = {};\n for (const r of agentRows) {\n agentBreakdown[r.agent_name] = r.cnt;\n }\n\n // Days in range for avg calculation\n const daysRow = this.db.prepare(`\n SELECT COUNT(DISTINCT date(timestamp / 1000, 'unixepoch')) as days\n FROM message_events ${where}\n `).get(...params) as any;\n const days = daysRow.days || 1;\n\n return {\n totalMessages: total,\n avgPerDay: total / days,\n peakDay: peakRow?.day ?? null,\n kbAnsweredPct: total > 0 ? (row.kb_answered / total) * 100 : 0,\n llmFallbackPct: total > 0 ? ((total - row.kb_answered) / total) * 100 : 0,\n noAnswerPct: total > 0 ? (row.no_answer / total) * 100 : 0,\n avgResponseTime: row.avg_response_time || 0,\n faqHitRate: total > 0 ? (row.faq_hits / total) * 100 : 0,\n uniqueCustomers: row.unique_customers || 0,\n newCustomers: row.new_customers || 0,\n returningCustomers: (row.unique_customers || 0) - (row.new_customers || 0),\n agentBreakdown,\n pendingReviews: 0, // populated externally from copilot\n };\n }\n\n async getMessageVolume(range?: TimeRange, groupBy: 'day' | 'hour' = 'day'): Promise<MessageVolumeData[]> {\n const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';\n const params = range ? [range.from, range.to] : [];\n\n const dateExpr = groupBy === 'hour'\n ? \"strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch')\"\n : \"date(timestamp / 1000, 'unixepoch')\";\n\n const rows = this.db.prepare(`\n SELECT ${dateExpr} as date, COUNT(*) as count\n FROM message_events ${where}\n GROUP BY date ORDER BY date\n `).all(...params) as any[];\n\n return rows.map(r => ({ date: r.date, count: r.count }));\n }\n\n async getAgentStats(range?: TimeRange): Promise<AgentStatsData[]> {\n const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';\n const params = range ? [range.from, range.to] : [];\n\n const rows = this.db.prepare(`\n SELECT\n agent_name,\n COUNT(*) as message_count,\n AVG(response_time_ms) as avg_response_time,\n AVG(intent_confidence) as avg_confidence,\n SUM(CASE WHEN was_escalated = 1 THEN 1 ELSE 0 END) as escalation_count,\n SUM(tool_calls_count) as tool_call_count\n FROM message_events ${where}\n GROUP BY agent_name\n `).all(...params) as any[];\n\n return rows.map(r => ({\n agentName: r.agent_name,\n messageCount: r.message_count,\n avgResponseTimeMs: r.avg_response_time || 0,\n avgConfidence: r.avg_confidence || 0,\n escalationCount: r.escalation_count || 0,\n toolCallCount: r.tool_call_count || 0,\n }));\n }\n\n async getCustomerStats(range?: TimeRange, limit = 50): Promise<CustomerStatsData[]> {\n const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';\n const params: any[] = range ? [range.from, range.to, limit] : [limit];\n\n const rows = this.db.prepare(`\n SELECT\n customer_id,\n customer_phone,\n COUNT(*) as message_count,\n MIN(timestamp) as first_seen,\n MAX(timestamp) as last_seen,\n MAX(is_new_customer) as is_new\n FROM message_events ${where}\n GROUP BY customer_phone\n ORDER BY message_count DESC\n LIMIT ?\n `).all(...params) as any[];\n\n return rows.map(r => ({\n customerId: r.customer_id,\n customerPhone: r.customer_phone,\n messageCount: r.message_count,\n firstSeen: r.first_seen,\n lastSeen: r.last_seen,\n isNew: !!r.is_new,\n }));\n }\n\n async getKbStats(range?: TimeRange): Promise<KbStatsData> {\n const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';\n const params = range ? [range.from, range.to] : [];\n\n const row = this.db.prepare(`\n SELECT\n COUNT(*) as total,\n SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,\n AVG(kb_top_score) as avg_score\n FROM message_events ${where}\n `).get(...params) as any;\n\n const total = row.total || 0;\n\n const topHits = await this.getTopFaqHits(10, range);\n const topMisses = await this.getTopMisses(10, range);\n\n return {\n totalQueries: total,\n faqHits: row.faq_hits || 0,\n faqHitRate: total > 0 ? ((row.faq_hits || 0) / total) * 100 : 0,\n avgTopScore: row.avg_score || 0,\n topFaqHits: topHits,\n topMisses: topMisses,\n };\n }\n\n async getTopFaqHits(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number }>> {\n const where = range\n ? 'WHERE kb_is_faq_match = 1 AND timestamp >= ? AND timestamp <= ?'\n : 'WHERE kb_is_faq_match = 1';\n const params: any[] = range ? [range.from, range.to, limit] : [limit];\n\n const rows = this.db.prepare(`\n SELECT intent, COUNT(*) as count\n FROM message_events ${where}\n GROUP BY intent ORDER BY count DESC LIMIT ?\n `).all(...params) as any[];\n\n return rows.map(r => ({ intent: r.intent, count: r.count }));\n }\n\n async getTopMisses(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number; avgScore: number }>> {\n const where = range\n ? 'WHERE kb_top_score < 0.5 AND kb_top_score > 0 AND timestamp >= ? AND timestamp <= ?'\n : 'WHERE kb_top_score < 0.5 AND kb_top_score > 0';\n const params: any[] = range ? [range.from, range.to, limit] : [limit];\n\n const rows = this.db.prepare(`\n SELECT intent, COUNT(*) as count, AVG(kb_top_score) as avg_score\n FROM message_events ${where}\n GROUP BY intent ORDER BY count DESC LIMIT ?\n `).all(...params) as any[];\n\n return rows.map(r => ({ intent: r.intent, count: r.count, avgScore: r.avg_score }));\n }\n}\n","import type {\n AnalyticsStore,\n AnalyticsEvent,\n AnalyticsSummary,\n MessageVolumeData,\n AgentStatsData,\n CustomerStatsData,\n KbStatsData,\n TimeRange,\n} from './types.js';\n\nconst SESSION_GAP_MS = 30 * 60 * 1000;\n\n/**\n * In-memory analytics store for testing and development.\n */\nexport class InMemoryAnalyticsStore implements AnalyticsStore {\n private events: AnalyticsEvent[] = [];\n private conversations = new Map<string, {\n id: string;\n customerId: string;\n customerPhone: string;\n channel: string;\n agentId: string;\n startedAt: number;\n endedAt: number;\n messageCount: number;\n toolCallCount: number;\n totalCost: number;\n }>();\n\n async initialize(): Promise<void> {}\n async close(): Promise<void> {}\n\n async recordMessageEvent(event: AnalyticsEvent): Promise<void> {\n this.events.push({ ...event });\n }\n\n async getOrCreateConversation(\n customerPhone: string,\n channel: string,\n agentName: string,\n ): Promise<string> {\n const now = Date.now();\n const cutoff = now - SESSION_GAP_MS;\n\n for (const conv of this.conversations.values()) {\n if (\n conv.customerPhone === customerPhone &&\n conv.channel === channel &&\n conv.agentId === agentName &&\n conv.endedAt > cutoff\n ) {\n return conv.id;\n }\n }\n\n const id = crypto.randomUUID();\n this.conversations.set(id, {\n id,\n customerId: '',\n customerPhone,\n channel,\n agentId: agentName,\n startedAt: now,\n endedAt: now,\n messageCount: 0,\n toolCallCount: 0,\n totalCost: 0,\n });\n return id;\n }\n\n async updateConversationStats(\n conversationId: string,\n event: AnalyticsEvent,\n ): Promise<void> {\n const conv = this.conversations.get(conversationId);\n if (!conv) return;\n if (!conv.customerId) conv.customerId = event.customerId;\n conv.endedAt = event.timestamp;\n conv.messageCount += 1;\n conv.toolCallCount += event.toolCallsCount;\n conv.totalCost += event.llmCost;\n }\n\n private filter(range?: TimeRange): AnalyticsEvent[] {\n if (!range) return this.events;\n return this.events.filter(e => e.timestamp >= range.from && e.timestamp <= range.to);\n }\n\n async getSummary(range?: TimeRange): Promise<AnalyticsSummary> {\n const events = this.filter(range);\n const total = events.length;\n if (total === 0) {\n return {\n totalMessages: 0, avgPerDay: 0, peakDay: null,\n kbAnsweredPct: 0, llmFallbackPct: 0, noAnswerPct: 0,\n avgResponseTime: 0, faqHitRate: 0, uniqueCustomers: 0,\n newCustomers: 0, returningCustomers: 0, agentBreakdown: {},\n pendingReviews: 0,\n };\n }\n\n const customers = new Set(events.map(e => e.customerPhone));\n const newCust = events.filter(e => e.isNewCustomer).length;\n const faqHits = events.filter(e => e.kbIsFaqMatch).length;\n const kbAnswered = events.filter(e => e.kbTopScore >= 0.7).length;\n const noAnswer = events.filter(e => e.kbTopScore < 0.3).length;\n const avgResp = events.reduce((s, e) => s + e.responseTimeMs, 0) / total;\n\n // Peak day\n const dayCounts = new Map<string, number>();\n for (const e of events) {\n const day = new Date(e.timestamp).toISOString().slice(0, 10);\n dayCounts.set(day, (dayCounts.get(day) || 0) + 1);\n }\n let peakDay: string | null = null;\n let peakCount = 0;\n for (const [day, cnt] of dayCounts) {\n if (cnt > peakCount) { peakDay = day; peakCount = cnt; }\n }\n\n // Agent breakdown\n const agentBreakdown: Record<string, number> = {};\n for (const e of events) {\n agentBreakdown[e.agentName] = (agentBreakdown[e.agentName] || 0) + 1;\n }\n\n const days = dayCounts.size || 1;\n\n return {\n totalMessages: total,\n avgPerDay: total / days,\n peakDay,\n kbAnsweredPct: (kbAnswered / total) * 100,\n llmFallbackPct: ((total - kbAnswered) / total) * 100,\n noAnswerPct: (noAnswer / total) * 100,\n avgResponseTime: avgResp,\n faqHitRate: (faqHits / total) * 100,\n uniqueCustomers: customers.size,\n newCustomers: newCust,\n returningCustomers: customers.size - newCust,\n agentBreakdown,\n pendingReviews: 0,\n };\n }\n\n async getMessageVolume(range?: TimeRange, groupBy: 'day' | 'hour' = 'day'): Promise<MessageVolumeData[]> {\n const events = this.filter(range);\n const buckets = new Map<string, number>();\n for (const e of events) {\n const d = new Date(e.timestamp);\n const key = groupBy === 'hour'\n ? d.toISOString().slice(0, 13) + ':00'\n : d.toISOString().slice(0, 10);\n buckets.set(key, (buckets.get(key) || 0) + 1);\n }\n return [...buckets.entries()]\n .sort(([a], [b]) => a.localeCompare(b))\n .map(([date, count]) => ({ date, count }));\n }\n\n async getAgentStats(range?: TimeRange): Promise<AgentStatsData[]> {\n const events = this.filter(range);\n const map = new Map<string, { count: number; respTime: number; conf: number; esc: number; tools: number }>();\n for (const e of events) {\n const s = map.get(e.agentName) || { count: 0, respTime: 0, conf: 0, esc: 0, tools: 0 };\n s.count++;\n s.respTime += e.responseTimeMs;\n s.conf += e.intentConfidence;\n s.esc += e.wasEscalated ? 1 : 0;\n s.tools += e.toolCallsCount;\n map.set(e.agentName, s);\n }\n return [...map.entries()].map(([name, s]) => ({\n agentName: name,\n messageCount: s.count,\n avgResponseTimeMs: s.count > 0 ? s.respTime / s.count : 0,\n avgConfidence: s.count > 0 ? s.conf / s.count : 0,\n escalationCount: s.esc,\n toolCallCount: s.tools,\n }));\n }\n\n async getCustomerStats(range?: TimeRange, limit = 50): Promise<CustomerStatsData[]> {\n const events = this.filter(range);\n const map = new Map<string, { id: string; count: number; first: number; last: number; isNew: boolean }>();\n for (const e of events) {\n const s = map.get(e.customerPhone) || { id: e.customerId, count: 0, first: e.timestamp, last: e.timestamp, isNew: false };\n s.count++;\n if (e.timestamp < s.first) s.first = e.timestamp;\n if (e.timestamp > s.last) s.last = e.timestamp;\n if (e.isNewCustomer) s.isNew = true;\n map.set(e.customerPhone, s);\n }\n return [...map.entries()]\n .sort(([, a], [, b]) => b.count - a.count)\n .slice(0, limit)\n .map(([phone, s]) => ({\n customerId: s.id,\n customerPhone: phone,\n messageCount: s.count,\n firstSeen: s.first,\n lastSeen: s.last,\n isNew: s.isNew,\n }));\n }\n\n async getKbStats(range?: TimeRange): Promise<KbStatsData> {\n const events = this.filter(range);\n const total = events.length;\n const faqHits = events.filter(e => e.kbIsFaqMatch).length;\n const avgScore = total > 0 ? events.reduce((s, e) => s + e.kbTopScore, 0) / total : 0;\n\n return {\n totalQueries: total,\n faqHits,\n faqHitRate: total > 0 ? (faqHits / total) * 100 : 0,\n avgTopScore: avgScore,\n topFaqHits: await this.getTopFaqHits(10, range),\n topMisses: await this.getTopMisses(10, range),\n };\n }\n\n async getTopFaqHits(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number }>> {\n const events = this.filter(range).filter(e => e.kbIsFaqMatch);\n const map = new Map<string, number>();\n for (const e of events) map.set(e.intent, (map.get(e.intent) || 0) + 1);\n return [...map.entries()]\n .sort(([, a], [, b]) => b - a)\n .slice(0, limit)\n .map(([intent, count]) => ({ intent, count }));\n }\n\n async getTopMisses(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number; avgScore: number }>> {\n const events = this.filter(range).filter(e => e.kbTopScore < 0.5 && e.kbTopScore > 0);\n const map = new Map<string, { count: number; totalScore: number }>();\n for (const e of events) {\n const s = map.get(e.intent) || { count: 0, totalScore: 0 };\n s.count++;\n s.totalScore += e.kbTopScore;\n map.set(e.intent, s);\n }\n return [...map.entries()]\n .sort(([, a], [, b]) => b.count - a.count)\n .slice(0, limit)\n .map(([intent, s]) => ({ intent, count: s.count, avgScore: s.totalScore / s.count }));\n }\n}\n","import type { AnalyticsStore, AnalyticsEvent } from './types.js';\n\n/**\n * Event payload from Operor 'message:processed' event.\n * Kept loose to avoid a hard dependency on @operor/core.\n */\ninterface MessageProcessedPayload {\n message: {\n id?: string;\n from: string;\n text: string;\n timestamp: number;\n channel: string;\n provider: string;\n };\n response: {\n text: string;\n duration: number;\n cost?: number;\n toolCalls?: Array<{ name: string; params: any; result?: any; success?: boolean }>;\n metadata?: Record<string, any>;\n };\n agent: string;\n intent: {\n intent: string;\n confidence: number;\n };\n customer: {\n id: string;\n phone?: string;\n firstInteraction?: Date;\n };\n toolCalls?: Array<{ name: string; params: any }>;\n duration: number;\n cost?: number;\n}\n\n/** Minimal EventEmitter interface — matches eventemitter3 used by Operor */\ninterface Emitter {\n on(event: string, handler: (...args: any[]) => void): void;\n off(event: string, handler: (...args: any[]) => void): void;\n}\n\n/**\n * Listens to Operor events and persists analytics data.\n */\nexport class AnalyticsCollector {\n private store: AnalyticsStore;\n private debug: boolean;\n private boundHandlers = new Map<string, (...args: any[]) => void>();\n\n constructor(store: AnalyticsStore, options?: { debug?: boolean }) {\n this.store = store;\n this.debug = options?.debug ?? false;\n }\n\n /**\n * Attach to an Operor instance (or any EventEmitter with the same events).\n */\n attach(os: Emitter): void {\n const onProcessed = (payload: MessageProcessedPayload) => {\n this.handleProcessed(payload).catch(err => {\n if (this.debug) console.warn('[Analytics] Error recording event:', err?.message);\n });\n };\n\n const onError = (payload: { error: any; message?: any }) => {\n this.handleError(payload).catch(err => {\n if (this.debug) console.warn('[Analytics] Error recording error event:', err?.message);\n });\n };\n\n os.on('message:processed', onProcessed);\n os.on('error', onError);\n\n this.boundHandlers.set('message:processed', onProcessed);\n this.boundHandlers.set('error', onError);\n }\n\n /**\n * Detach from an Operor instance.\n */\n detach(os: Emitter): void {\n for (const [event, handler] of this.boundHandlers) {\n os.off(event, handler);\n }\n this.boundHandlers.clear();\n }\n\n private async handleProcessed(payload: MessageProcessedPayload): Promise<void> {\n const { message, response, agent, intent, customer } = payload;\n\n const toolCalls = response.toolCalls || payload.toolCalls || [];\n const meta = response.metadata || {};\n\n // Detect if this is a new customer (first interaction is very recent)\n const isNew = customer.firstInteraction\n ? (Date.now() - customer.firstInteraction.getTime()) < 60_000\n : false;\n\n // Get or create conversation session\n const conversationId = await this.store.getOrCreateConversation(\n message.from,\n message.channel || message.provider,\n agent,\n );\n\n const event: AnalyticsEvent = {\n timestamp: message.timestamp || Date.now(),\n channel: message.channel || message.provider,\n customerPhone: message.from,\n customerId: customer.id,\n agentName: agent,\n intent: intent.intent,\n intentConfidence: intent.confidence,\n responseTimeMs: response.duration || payload.duration || 0,\n llmCost: response.cost ?? payload.cost ?? 0,\n kbTopScore: meta.kbTopScore ?? 0,\n kbIsFaqMatch: meta.kbIsFaqMatch ?? false,\n kbResultCount: meta.kbResultCount ?? 0,\n toolCallsCount: toolCalls.length,\n toolCallsJson: toolCalls.length > 0 ? JSON.stringify(toolCalls) : null,\n messageLength: message.text?.length ?? 0,\n responseLength: response.text?.length ?? 0,\n isNewCustomer: isNew,\n wasEscalated: false, // set externally if escalation detected\n conversationId,\n promptTokens: meta.promptTokens ?? 0,\n completionTokens: meta.completionTokens ?? 0,\n };\n\n await this.store.recordMessageEvent(event);\n await this.store.updateConversationStats(conversationId, event);\n\n if (this.debug) {\n this.log(`Recorded event: agent=${agent} customer=${message.from} intent=${intent.intent}`);\n }\n }\n\n private async handleError(_payload: { error: any; message?: any }): Promise<void> {\n // Error events don't have enough structured data to record as full events.\n // Future: could track error counts in daily_metrics.\n }\n\n private log(msg: string): void {\n console.log(`[Analytics] ${msg}`);\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@operor/analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Analytics — message event tracking, conversation sessions, and aggregation queries",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"better-sqlite3": "^12.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
19
|
+
"@types/node": "^22.0.0",
|
|
20
|
+
"tsdown": "^0.20.3",
|
|
21
|
+
"typescript": "^5.7.0",
|
|
22
|
+
"vitest": "^4.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest"
|
|
28
|
+
}
|
|
29
|
+
}
|