@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.
@@ -0,0 +1,250 @@
1
+ import type {
2
+ AnalyticsStore,
3
+ AnalyticsEvent,
4
+ AnalyticsSummary,
5
+ MessageVolumeData,
6
+ AgentStatsData,
7
+ CustomerStatsData,
8
+ KbStatsData,
9
+ TimeRange,
10
+ } from './types.js';
11
+
12
+ const SESSION_GAP_MS = 30 * 60 * 1000;
13
+
14
+ /**
15
+ * In-memory analytics store for testing and development.
16
+ */
17
+ export class InMemoryAnalyticsStore implements AnalyticsStore {
18
+ private events: AnalyticsEvent[] = [];
19
+ private conversations = new Map<string, {
20
+ id: string;
21
+ customerId: string;
22
+ customerPhone: string;
23
+ channel: string;
24
+ agentId: string;
25
+ startedAt: number;
26
+ endedAt: number;
27
+ messageCount: number;
28
+ toolCallCount: number;
29
+ totalCost: number;
30
+ }>();
31
+
32
+ async initialize(): Promise<void> {}
33
+ async close(): Promise<void> {}
34
+
35
+ async recordMessageEvent(event: AnalyticsEvent): Promise<void> {
36
+ this.events.push({ ...event });
37
+ }
38
+
39
+ async getOrCreateConversation(
40
+ customerPhone: string,
41
+ channel: string,
42
+ agentName: string,
43
+ ): Promise<string> {
44
+ const now = Date.now();
45
+ const cutoff = now - SESSION_GAP_MS;
46
+
47
+ for (const conv of this.conversations.values()) {
48
+ if (
49
+ conv.customerPhone === customerPhone &&
50
+ conv.channel === channel &&
51
+ conv.agentId === agentName &&
52
+ conv.endedAt > cutoff
53
+ ) {
54
+ return conv.id;
55
+ }
56
+ }
57
+
58
+ const id = crypto.randomUUID();
59
+ this.conversations.set(id, {
60
+ id,
61
+ customerId: '',
62
+ customerPhone,
63
+ channel,
64
+ agentId: agentName,
65
+ startedAt: now,
66
+ endedAt: now,
67
+ messageCount: 0,
68
+ toolCallCount: 0,
69
+ totalCost: 0,
70
+ });
71
+ return id;
72
+ }
73
+
74
+ async updateConversationStats(
75
+ conversationId: string,
76
+ event: AnalyticsEvent,
77
+ ): Promise<void> {
78
+ const conv = this.conversations.get(conversationId);
79
+ if (!conv) return;
80
+ if (!conv.customerId) conv.customerId = event.customerId;
81
+ conv.endedAt = event.timestamp;
82
+ conv.messageCount += 1;
83
+ conv.toolCallCount += event.toolCallsCount;
84
+ conv.totalCost += event.llmCost;
85
+ }
86
+
87
+ private filter(range?: TimeRange): AnalyticsEvent[] {
88
+ if (!range) return this.events;
89
+ return this.events.filter(e => e.timestamp >= range.from && e.timestamp <= range.to);
90
+ }
91
+
92
+ async getSummary(range?: TimeRange): Promise<AnalyticsSummary> {
93
+ const events = this.filter(range);
94
+ const total = events.length;
95
+ if (total === 0) {
96
+ return {
97
+ totalMessages: 0, avgPerDay: 0, peakDay: null,
98
+ kbAnsweredPct: 0, llmFallbackPct: 0, noAnswerPct: 0,
99
+ avgResponseTime: 0, faqHitRate: 0, uniqueCustomers: 0,
100
+ newCustomers: 0, returningCustomers: 0, agentBreakdown: {},
101
+ pendingReviews: 0,
102
+ };
103
+ }
104
+
105
+ const customers = new Set(events.map(e => e.customerPhone));
106
+ const newCust = events.filter(e => e.isNewCustomer).length;
107
+ const faqHits = events.filter(e => e.kbIsFaqMatch).length;
108
+ const kbAnswered = events.filter(e => e.kbTopScore >= 0.7).length;
109
+ const noAnswer = events.filter(e => e.kbTopScore < 0.3).length;
110
+ const avgResp = events.reduce((s, e) => s + e.responseTimeMs, 0) / total;
111
+
112
+ // Peak day
113
+ const dayCounts = new Map<string, number>();
114
+ for (const e of events) {
115
+ const day = new Date(e.timestamp).toISOString().slice(0, 10);
116
+ dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
117
+ }
118
+ let peakDay: string | null = null;
119
+ let peakCount = 0;
120
+ for (const [day, cnt] of dayCounts) {
121
+ if (cnt > peakCount) { peakDay = day; peakCount = cnt; }
122
+ }
123
+
124
+ // Agent breakdown
125
+ const agentBreakdown: Record<string, number> = {};
126
+ for (const e of events) {
127
+ agentBreakdown[e.agentName] = (agentBreakdown[e.agentName] || 0) + 1;
128
+ }
129
+
130
+ const days = dayCounts.size || 1;
131
+
132
+ return {
133
+ totalMessages: total,
134
+ avgPerDay: total / days,
135
+ peakDay,
136
+ kbAnsweredPct: (kbAnswered / total) * 100,
137
+ llmFallbackPct: ((total - kbAnswered) / total) * 100,
138
+ noAnswerPct: (noAnswer / total) * 100,
139
+ avgResponseTime: avgResp,
140
+ faqHitRate: (faqHits / total) * 100,
141
+ uniqueCustomers: customers.size,
142
+ newCustomers: newCust,
143
+ returningCustomers: customers.size - newCust,
144
+ agentBreakdown,
145
+ pendingReviews: 0,
146
+ };
147
+ }
148
+
149
+ async getMessageVolume(range?: TimeRange, groupBy: 'day' | 'hour' = 'day'): Promise<MessageVolumeData[]> {
150
+ const events = this.filter(range);
151
+ const buckets = new Map<string, number>();
152
+ for (const e of events) {
153
+ const d = new Date(e.timestamp);
154
+ const key = groupBy === 'hour'
155
+ ? d.toISOString().slice(0, 13) + ':00'
156
+ : d.toISOString().slice(0, 10);
157
+ buckets.set(key, (buckets.get(key) || 0) + 1);
158
+ }
159
+ return [...buckets.entries()]
160
+ .sort(([a], [b]) => a.localeCompare(b))
161
+ .map(([date, count]) => ({ date, count }));
162
+ }
163
+
164
+ async getAgentStats(range?: TimeRange): Promise<AgentStatsData[]> {
165
+ const events = this.filter(range);
166
+ const map = new Map<string, { count: number; respTime: number; conf: number; esc: number; tools: number }>();
167
+ for (const e of events) {
168
+ const s = map.get(e.agentName) || { count: 0, respTime: 0, conf: 0, esc: 0, tools: 0 };
169
+ s.count++;
170
+ s.respTime += e.responseTimeMs;
171
+ s.conf += e.intentConfidence;
172
+ s.esc += e.wasEscalated ? 1 : 0;
173
+ s.tools += e.toolCallsCount;
174
+ map.set(e.agentName, s);
175
+ }
176
+ return [...map.entries()].map(([name, s]) => ({
177
+ agentName: name,
178
+ messageCount: s.count,
179
+ avgResponseTimeMs: s.count > 0 ? s.respTime / s.count : 0,
180
+ avgConfidence: s.count > 0 ? s.conf / s.count : 0,
181
+ escalationCount: s.esc,
182
+ toolCallCount: s.tools,
183
+ }));
184
+ }
185
+
186
+ async getCustomerStats(range?: TimeRange, limit = 50): Promise<CustomerStatsData[]> {
187
+ const events = this.filter(range);
188
+ const map = new Map<string, { id: string; count: number; first: number; last: number; isNew: boolean }>();
189
+ for (const e of events) {
190
+ const s = map.get(e.customerPhone) || { id: e.customerId, count: 0, first: e.timestamp, last: e.timestamp, isNew: false };
191
+ s.count++;
192
+ if (e.timestamp < s.first) s.first = e.timestamp;
193
+ if (e.timestamp > s.last) s.last = e.timestamp;
194
+ if (e.isNewCustomer) s.isNew = true;
195
+ map.set(e.customerPhone, s);
196
+ }
197
+ return [...map.entries()]
198
+ .sort(([, a], [, b]) => b.count - a.count)
199
+ .slice(0, limit)
200
+ .map(([phone, s]) => ({
201
+ customerId: s.id,
202
+ customerPhone: phone,
203
+ messageCount: s.count,
204
+ firstSeen: s.first,
205
+ lastSeen: s.last,
206
+ isNew: s.isNew,
207
+ }));
208
+ }
209
+
210
+ async getKbStats(range?: TimeRange): Promise<KbStatsData> {
211
+ const events = this.filter(range);
212
+ const total = events.length;
213
+ const faqHits = events.filter(e => e.kbIsFaqMatch).length;
214
+ const avgScore = total > 0 ? events.reduce((s, e) => s + e.kbTopScore, 0) / total : 0;
215
+
216
+ return {
217
+ totalQueries: total,
218
+ faqHits,
219
+ faqHitRate: total > 0 ? (faqHits / total) * 100 : 0,
220
+ avgTopScore: avgScore,
221
+ topFaqHits: await this.getTopFaqHits(10, range),
222
+ topMisses: await this.getTopMisses(10, range),
223
+ };
224
+ }
225
+
226
+ async getTopFaqHits(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number }>> {
227
+ const events = this.filter(range).filter(e => e.kbIsFaqMatch);
228
+ const map = new Map<string, number>();
229
+ for (const e of events) map.set(e.intent, (map.get(e.intent) || 0) + 1);
230
+ return [...map.entries()]
231
+ .sort(([, a], [, b]) => b - a)
232
+ .slice(0, limit)
233
+ .map(([intent, count]) => ({ intent, count }));
234
+ }
235
+
236
+ async getTopMisses(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number; avgScore: number }>> {
237
+ const events = this.filter(range).filter(e => e.kbTopScore < 0.5 && e.kbTopScore > 0);
238
+ const map = new Map<string, { count: number; totalScore: number }>();
239
+ for (const e of events) {
240
+ const s = map.get(e.intent) || { count: 0, totalScore: 0 };
241
+ s.count++;
242
+ s.totalScore += e.kbTopScore;
243
+ map.set(e.intent, s);
244
+ }
245
+ return [...map.entries()]
246
+ .sort(([, a], [, b]) => b.count - a.count)
247
+ .slice(0, limit)
248
+ .map(([intent, s]) => ({ intent, count: s.count, avgScore: s.totalScore / s.count }));
249
+ }
250
+ }