@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,367 @@
1
+ import Database from 'better-sqlite3';
2
+ import type {
3
+ AnalyticsStore,
4
+ AnalyticsEvent,
5
+ AnalyticsSummary,
6
+ MessageVolumeData,
7
+ AgentStatsData,
8
+ CustomerStatsData,
9
+ KbStatsData,
10
+ ConversationSession,
11
+ TimeRange,
12
+ } from './types.js';
13
+
14
+ const SESSION_GAP_MS = 30 * 60 * 1000; // 30 minutes
15
+
16
+ export class SQLiteAnalyticsStore implements AnalyticsStore {
17
+ private db: Database.Database;
18
+
19
+ constructor(dbPath: string = './analytics.db') {
20
+ this.db = new Database(dbPath);
21
+ this.db.pragma('journal_mode = WAL');
22
+ this.db.pragma('foreign_keys = ON');
23
+ }
24
+
25
+ async initialize(): Promise<void> {
26
+ this.db.exec(`
27
+ CREATE TABLE IF NOT EXISTS message_events (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ timestamp INTEGER NOT NULL,
30
+ channel TEXT NOT NULL,
31
+ customer_phone TEXT NOT NULL,
32
+ customer_id TEXT NOT NULL,
33
+ agent_name TEXT NOT NULL,
34
+ intent TEXT NOT NULL DEFAULT '',
35
+ intent_confidence REAL NOT NULL DEFAULT 0,
36
+ response_time_ms INTEGER NOT NULL DEFAULT 0,
37
+ llm_cost REAL NOT NULL DEFAULT 0,
38
+ kb_top_score REAL NOT NULL DEFAULT 0,
39
+ kb_is_faq_match INTEGER NOT NULL DEFAULT 0,
40
+ kb_result_count INTEGER NOT NULL DEFAULT 0,
41
+ tool_calls_count INTEGER NOT NULL DEFAULT 0,
42
+ tool_calls_json TEXT,
43
+ message_length INTEGER NOT NULL DEFAULT 0,
44
+ response_length INTEGER NOT NULL DEFAULT 0,
45
+ is_new_customer INTEGER NOT NULL DEFAULT 0,
46
+ was_escalated INTEGER NOT NULL DEFAULT 0,
47
+ conversation_id TEXT,
48
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
49
+ completion_tokens INTEGER NOT NULL DEFAULT 0
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON message_events(timestamp);
53
+ CREATE INDEX IF NOT EXISTS idx_events_agent ON message_events(agent_name);
54
+ CREATE INDEX IF NOT EXISTS idx_events_customer ON message_events(customer_phone);
55
+ CREATE INDEX IF NOT EXISTS idx_events_conversation ON message_events(conversation_id);
56
+
57
+ CREATE TABLE IF NOT EXISTS conversations (
58
+ id TEXT PRIMARY KEY,
59
+ customer_id TEXT NOT NULL,
60
+ customer_phone TEXT NOT NULL,
61
+ channel TEXT NOT NULL,
62
+ agent_id TEXT NOT NULL,
63
+ started_at INTEGER NOT NULL,
64
+ ended_at INTEGER NOT NULL,
65
+ message_count INTEGER NOT NULL DEFAULT 0,
66
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
67
+ total_duration_ms INTEGER NOT NULL DEFAULT 0,
68
+ total_cost REAL NOT NULL DEFAULT 0,
69
+ resolved INTEGER NOT NULL DEFAULT 0,
70
+ resolution_type TEXT
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS daily_metrics (
74
+ date TEXT NOT NULL,
75
+ metric TEXT NOT NULL,
76
+ channel TEXT NOT NULL DEFAULT '',
77
+ agent_id TEXT NOT NULL DEFAULT '',
78
+ value REAL NOT NULL DEFAULT 0,
79
+ PRIMARY KEY (date, metric, channel, agent_id)
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS analytics_meta (
83
+ key TEXT PRIMARY KEY,
84
+ value TEXT NOT NULL
85
+ );
86
+ `);
87
+ }
88
+
89
+ async close(): Promise<void> {
90
+ this.db.close();
91
+ }
92
+
93
+ async recordMessageEvent(event: AnalyticsEvent): Promise<void> {
94
+ this.db.prepare(`
95
+ INSERT INTO message_events
96
+ (timestamp, channel, customer_phone, customer_id, agent_name, intent,
97
+ intent_confidence, response_time_ms, llm_cost, kb_top_score, kb_is_faq_match,
98
+ kb_result_count, tool_calls_count, tool_calls_json, message_length,
99
+ response_length, is_new_customer, was_escalated, conversation_id,
100
+ prompt_tokens, completion_tokens)
101
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
102
+ `).run(
103
+ event.timestamp,
104
+ event.channel,
105
+ event.customerPhone,
106
+ event.customerId,
107
+ event.agentName,
108
+ event.intent,
109
+ event.intentConfidence,
110
+ event.responseTimeMs,
111
+ event.llmCost,
112
+ event.kbTopScore,
113
+ event.kbIsFaqMatch ? 1 : 0,
114
+ event.kbResultCount,
115
+ event.toolCallsCount,
116
+ event.toolCallsJson,
117
+ event.messageLength,
118
+ event.responseLength,
119
+ event.isNewCustomer ? 1 : 0,
120
+ event.wasEscalated ? 1 : 0,
121
+ event.conversationId,
122
+ event.promptTokens,
123
+ event.completionTokens,
124
+ );
125
+ }
126
+
127
+ async getOrCreateConversation(
128
+ customerPhone: string,
129
+ channel: string,
130
+ agentName: string,
131
+ ): Promise<string> {
132
+ const now = Date.now();
133
+ const cutoff = now - SESSION_GAP_MS;
134
+
135
+ // Find an active conversation within the session gap
136
+ const existing = this.db.prepare(`
137
+ SELECT id FROM conversations
138
+ WHERE customer_phone = ? AND channel = ? AND agent_id = ? AND ended_at > ?
139
+ ORDER BY ended_at DESC LIMIT 1
140
+ `).get(customerPhone, channel, agentName, cutoff) as any;
141
+
142
+ if (existing) {
143
+ return existing.id as string;
144
+ }
145
+
146
+ // Create new conversation
147
+ const id = crypto.randomUUID();
148
+ this.db.prepare(`
149
+ INSERT INTO conversations (id, customer_id, customer_phone, channel, agent_id, started_at, ended_at)
150
+ VALUES (?, ?, ?, ?, ?, ?, ?)
151
+ `).run(id, '', customerPhone, channel, agentName, now, now);
152
+
153
+ return id;
154
+ }
155
+
156
+ async updateConversationStats(
157
+ conversationId: string,
158
+ event: AnalyticsEvent,
159
+ ): Promise<void> {
160
+ this.db.prepare(`
161
+ UPDATE conversations SET
162
+ customer_id = CASE WHEN customer_id = '' THEN ? ELSE customer_id END,
163
+ ended_at = ?,
164
+ message_count = message_count + 1,
165
+ tool_call_count = tool_call_count + ?,
166
+ total_duration_ms = ? - started_at,
167
+ total_cost = total_cost + ?
168
+ WHERE id = ?
169
+ `).run(
170
+ event.customerId,
171
+ event.timestamp,
172
+ event.toolCallsCount,
173
+ event.timestamp,
174
+ event.llmCost,
175
+ conversationId,
176
+ );
177
+ }
178
+
179
+ async getSummary(range?: TimeRange): Promise<AnalyticsSummary> {
180
+ const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';
181
+ const params = range ? [range.from, range.to] : [];
182
+
183
+ const row = this.db.prepare(`
184
+ SELECT
185
+ COUNT(*) as total,
186
+ AVG(response_time_ms) as avg_response_time,
187
+ COUNT(DISTINCT customer_phone) as unique_customers,
188
+ SUM(CASE WHEN is_new_customer = 1 THEN 1 ELSE 0 END) as new_customers,
189
+ SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,
190
+ SUM(CASE WHEN kb_top_score >= 0.7 THEN 1 ELSE 0 END) as kb_answered,
191
+ SUM(CASE WHEN kb_top_score < 0.3 THEN 1 ELSE 0 END) as no_answer
192
+ FROM message_events ${where}
193
+ `).get(...params) as any;
194
+
195
+ const total = row.total || 0;
196
+
197
+ // Peak day
198
+ const peakRow = this.db.prepare(`
199
+ SELECT date(timestamp / 1000, 'unixepoch') as day, COUNT(*) as cnt
200
+ FROM message_events ${where}
201
+ GROUP BY day ORDER BY cnt DESC LIMIT 1
202
+ `).get(...params) as any;
203
+
204
+ // Agent breakdown
205
+ const agentRows = this.db.prepare(`
206
+ SELECT agent_name, COUNT(*) as cnt
207
+ FROM message_events ${where}
208
+ GROUP BY agent_name
209
+ `).all(...params) as any[];
210
+
211
+ const agentBreakdown: Record<string, number> = {};
212
+ for (const r of agentRows) {
213
+ agentBreakdown[r.agent_name] = r.cnt;
214
+ }
215
+
216
+ // Days in range for avg calculation
217
+ const daysRow = this.db.prepare(`
218
+ SELECT COUNT(DISTINCT date(timestamp / 1000, 'unixepoch')) as days
219
+ FROM message_events ${where}
220
+ `).get(...params) as any;
221
+ const days = daysRow.days || 1;
222
+
223
+ return {
224
+ totalMessages: total,
225
+ avgPerDay: total / days,
226
+ peakDay: peakRow?.day ?? null,
227
+ kbAnsweredPct: total > 0 ? (row.kb_answered / total) * 100 : 0,
228
+ llmFallbackPct: total > 0 ? ((total - row.kb_answered) / total) * 100 : 0,
229
+ noAnswerPct: total > 0 ? (row.no_answer / total) * 100 : 0,
230
+ avgResponseTime: row.avg_response_time || 0,
231
+ faqHitRate: total > 0 ? (row.faq_hits / total) * 100 : 0,
232
+ uniqueCustomers: row.unique_customers || 0,
233
+ newCustomers: row.new_customers || 0,
234
+ returningCustomers: (row.unique_customers || 0) - (row.new_customers || 0),
235
+ agentBreakdown,
236
+ pendingReviews: 0, // populated externally from copilot
237
+ };
238
+ }
239
+
240
+ async getMessageVolume(range?: TimeRange, groupBy: 'day' | 'hour' = 'day'): Promise<MessageVolumeData[]> {
241
+ const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';
242
+ const params = range ? [range.from, range.to] : [];
243
+
244
+ const dateExpr = groupBy === 'hour'
245
+ ? "strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch')"
246
+ : "date(timestamp / 1000, 'unixepoch')";
247
+
248
+ const rows = this.db.prepare(`
249
+ SELECT ${dateExpr} as date, COUNT(*) as count
250
+ FROM message_events ${where}
251
+ GROUP BY date ORDER BY date
252
+ `).all(...params) as any[];
253
+
254
+ return rows.map(r => ({ date: r.date, count: r.count }));
255
+ }
256
+
257
+ async getAgentStats(range?: TimeRange): Promise<AgentStatsData[]> {
258
+ const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';
259
+ const params = range ? [range.from, range.to] : [];
260
+
261
+ const rows = this.db.prepare(`
262
+ SELECT
263
+ agent_name,
264
+ COUNT(*) as message_count,
265
+ AVG(response_time_ms) as avg_response_time,
266
+ AVG(intent_confidence) as avg_confidence,
267
+ SUM(CASE WHEN was_escalated = 1 THEN 1 ELSE 0 END) as escalation_count,
268
+ SUM(tool_calls_count) as tool_call_count
269
+ FROM message_events ${where}
270
+ GROUP BY agent_name
271
+ `).all(...params) as any[];
272
+
273
+ return rows.map(r => ({
274
+ agentName: r.agent_name,
275
+ messageCount: r.message_count,
276
+ avgResponseTimeMs: r.avg_response_time || 0,
277
+ avgConfidence: r.avg_confidence || 0,
278
+ escalationCount: r.escalation_count || 0,
279
+ toolCallCount: r.tool_call_count || 0,
280
+ }));
281
+ }
282
+
283
+ async getCustomerStats(range?: TimeRange, limit = 50): Promise<CustomerStatsData[]> {
284
+ const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';
285
+ const params: any[] = range ? [range.from, range.to, limit] : [limit];
286
+
287
+ const rows = this.db.prepare(`
288
+ SELECT
289
+ customer_id,
290
+ customer_phone,
291
+ COUNT(*) as message_count,
292
+ MIN(timestamp) as first_seen,
293
+ MAX(timestamp) as last_seen,
294
+ MAX(is_new_customer) as is_new
295
+ FROM message_events ${where}
296
+ GROUP BY customer_phone
297
+ ORDER BY message_count DESC
298
+ LIMIT ?
299
+ `).all(...params) as any[];
300
+
301
+ return rows.map(r => ({
302
+ customerId: r.customer_id,
303
+ customerPhone: r.customer_phone,
304
+ messageCount: r.message_count,
305
+ firstSeen: r.first_seen,
306
+ lastSeen: r.last_seen,
307
+ isNew: !!r.is_new,
308
+ }));
309
+ }
310
+
311
+ async getKbStats(range?: TimeRange): Promise<KbStatsData> {
312
+ const where = range ? 'WHERE timestamp >= ? AND timestamp <= ?' : '';
313
+ const params = range ? [range.from, range.to] : [];
314
+
315
+ const row = this.db.prepare(`
316
+ SELECT
317
+ COUNT(*) as total,
318
+ SUM(CASE WHEN kb_is_faq_match = 1 THEN 1 ELSE 0 END) as faq_hits,
319
+ AVG(kb_top_score) as avg_score
320
+ FROM message_events ${where}
321
+ `).get(...params) as any;
322
+
323
+ const total = row.total || 0;
324
+
325
+ const topHits = await this.getTopFaqHits(10, range);
326
+ const topMisses = await this.getTopMisses(10, range);
327
+
328
+ return {
329
+ totalQueries: total,
330
+ faqHits: row.faq_hits || 0,
331
+ faqHitRate: total > 0 ? ((row.faq_hits || 0) / total) * 100 : 0,
332
+ avgTopScore: row.avg_score || 0,
333
+ topFaqHits: topHits,
334
+ topMisses: topMisses,
335
+ };
336
+ }
337
+
338
+ async getTopFaqHits(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number }>> {
339
+ const where = range
340
+ ? 'WHERE kb_is_faq_match = 1 AND timestamp >= ? AND timestamp <= ?'
341
+ : 'WHERE kb_is_faq_match = 1';
342
+ const params: any[] = range ? [range.from, range.to, limit] : [limit];
343
+
344
+ const rows = this.db.prepare(`
345
+ SELECT intent, COUNT(*) as count
346
+ FROM message_events ${where}
347
+ GROUP BY intent ORDER BY count DESC LIMIT ?
348
+ `).all(...params) as any[];
349
+
350
+ return rows.map(r => ({ intent: r.intent, count: r.count }));
351
+ }
352
+
353
+ async getTopMisses(limit = 10, range?: TimeRange): Promise<Array<{ intent: string; count: number; avgScore: number }>> {
354
+ const where = range
355
+ ? 'WHERE kb_top_score < 0.5 AND kb_top_score > 0 AND timestamp >= ? AND timestamp <= ?'
356
+ : 'WHERE kb_top_score < 0.5 AND kb_top_score > 0';
357
+ const params: any[] = range ? [range.from, range.to, limit] : [limit];
358
+
359
+ const rows = this.db.prepare(`
360
+ SELECT intent, COUNT(*) as count, AVG(kb_top_score) as avg_score
361
+ FROM message_events ${where}
362
+ GROUP BY intent ORDER BY count DESC LIMIT ?
363
+ `).all(...params) as any[];
364
+
365
+ return rows.map(r => ({ intent: r.intent, count: r.count, avgScore: r.avg_score }));
366
+ }
367
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { SQLiteAnalyticsStore } from './SQLiteAnalyticsStore.js';
3
+ export { InMemoryAnalyticsStore } from './InMemoryAnalyticsStore.js';
4
+ export { AnalyticsCollector } from './AnalyticsCollector.js';
package/src/types.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Analytics types for Operor
3
+ */
4
+
5
+ /** A single message event recorded by the analytics collector */
6
+ export interface AnalyticsEvent {
7
+ id?: number;
8
+ timestamp: number;
9
+ channel: string;
10
+ customerPhone: string;
11
+ customerId: string;
12
+ agentName: string;
13
+ intent: string;
14
+ intentConfidence: number;
15
+ responseTimeMs: number;
16
+ llmCost: number;
17
+ kbTopScore: number;
18
+ kbIsFaqMatch: boolean;
19
+ kbResultCount: number;
20
+ toolCallsCount: number;
21
+ toolCallsJson: string | null;
22
+ messageLength: number;
23
+ responseLength: number;
24
+ isNewCustomer: boolean;
25
+ wasEscalated: boolean;
26
+ conversationId: string | null;
27
+ promptTokens: number;
28
+ completionTokens: number;
29
+ }
30
+
31
+ /** Aggregated summary for a time range */
32
+ export interface AnalyticsSummary {
33
+ totalMessages: number;
34
+ avgPerDay: number;
35
+ peakDay: string | null;
36
+ kbAnsweredPct: number;
37
+ llmFallbackPct: number;
38
+ noAnswerPct: number;
39
+ avgResponseTime: number;
40
+ faqHitRate: number;
41
+ uniqueCustomers: number;
42
+ newCustomers: number;
43
+ returningCustomers: number;
44
+ agentBreakdown: Record<string, number>;
45
+ pendingReviews: number;
46
+ }
47
+
48
+ /** Message volume data point (for charts) */
49
+ export interface MessageVolumeData {
50
+ date: string;
51
+ count: number;
52
+ channel?: string;
53
+ }
54
+
55
+ /** Per-agent stats */
56
+ export interface AgentStatsData {
57
+ agentName: string;
58
+ messageCount: number;
59
+ avgResponseTimeMs: number;
60
+ avgConfidence: number;
61
+ escalationCount: number;
62
+ toolCallCount: number;
63
+ }
64
+
65
+ /** Per-customer stats */
66
+ export interface CustomerStatsData {
67
+ customerId: string;
68
+ customerPhone: string;
69
+ messageCount: number;
70
+ firstSeen: number;
71
+ lastSeen: number;
72
+ isNew: boolean;
73
+ }
74
+
75
+ /** Knowledge Base stats from analytics */
76
+ export interface KbStatsData {
77
+ totalQueries: number;
78
+ faqHits: number;
79
+ faqHitRate: number;
80
+ avgTopScore: number;
81
+ topFaqHits: Array<{ intent: string; count: number }>;
82
+ topMisses: Array<{ intent: string; count: number; avgScore: number }>;
83
+ }
84
+
85
+ /** A conversation session (gap-based) */
86
+ export interface ConversationSession {
87
+ id: string;
88
+ customerId: string;
89
+ customerPhone: string;
90
+ channel: string;
91
+ agentId: string;
92
+ startedAt: number;
93
+ endedAt: number;
94
+ messageCount: number;
95
+ toolCallCount: number;
96
+ totalDurationMs: number;
97
+ totalCost: number;
98
+ resolved: boolean;
99
+ resolutionType: string | null;
100
+ }
101
+
102
+ /** Time range filter for queries */
103
+ export interface TimeRange {
104
+ from: number;
105
+ to: number;
106
+ }
107
+
108
+ /** Store interface for analytics persistence */
109
+ export interface AnalyticsStore {
110
+ initialize(): Promise<void>;
111
+ close(): Promise<void>;
112
+
113
+ /** Record a message event */
114
+ recordMessageEvent(event: AnalyticsEvent): Promise<void>;
115
+
116
+ /** Get or create a conversation session */
117
+ getOrCreateConversation(
118
+ customerPhone: string,
119
+ channel: string,
120
+ agentName: string,
121
+ ): Promise<string>;
122
+
123
+ /** Update conversation stats after a message */
124
+ updateConversationStats(
125
+ conversationId: string,
126
+ event: AnalyticsEvent,
127
+ ): Promise<void>;
128
+
129
+ /** Get aggregated summary for a time range */
130
+ getSummary(range?: TimeRange): Promise<AnalyticsSummary>;
131
+
132
+ /** Get message volume over time */
133
+ getMessageVolume(range?: TimeRange, groupBy?: 'day' | 'hour'): Promise<MessageVolumeData[]>;
134
+
135
+ /** Get per-agent stats */
136
+ getAgentStats(range?: TimeRange): Promise<AgentStatsData[]>;
137
+
138
+ /** Get per-customer stats */
139
+ getCustomerStats(range?: TimeRange, limit?: number): Promise<CustomerStatsData[]>;
140
+
141
+ /** Get KB-related stats */
142
+ getKbStats(range?: TimeRange): Promise<KbStatsData>;
143
+
144
+ /** Get top FAQ hits */
145
+ getTopFaqHits(limit?: number, range?: TimeRange): Promise<Array<{ intent: string; count: number }>>;
146
+
147
+ /** Get top misses (low KB score) */
148
+ getTopMisses(limit?: number, range?: TimeRange): Promise<Array<{ intent: string; count: number; avgScore: number }>>;
149
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'tsdown';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ clean: true,
8
+ sourcemap: true,
9
+ outExtensions: () => ({ js: '.js', dts: '.d.ts' }),
10
+ });