@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.
@@ -0,0 +1,300 @@
1
+ import Database from 'better-sqlite3';
2
+ import * as sqliteVec from 'sqlite-vec';
3
+ import type { CopilotStore, UnansweredQuery, QueryCluster, ImpactMetrics } from './types.js';
4
+
5
+ /**
6
+ * SQLite-backed copilot store with sqlite-vec for vector search on cluster centroids.
7
+ */
8
+ export class SQLiteCopilotStore implements CopilotStore {
9
+ private db: Database.Database;
10
+
11
+ constructor(
12
+ private dbPath: string,
13
+ private dimensions: number,
14
+ ) {
15
+ this.db = new Database(dbPath);
16
+ this.db.pragma('journal_mode = WAL');
17
+ this.db.pragma('foreign_keys = ON');
18
+ sqliteVec.load(this.db);
19
+ }
20
+
21
+ async initialize(): Promise<void> {
22
+ this.db.exec(`
23
+ CREATE TABLE IF NOT EXISTS copilot_queries (
24
+ id TEXT PRIMARY KEY,
25
+ query TEXT NOT NULL,
26
+ normalized_query TEXT NOT NULL,
27
+ channel TEXT NOT NULL,
28
+ customer_phone TEXT NOT NULL,
29
+ kb_top_score REAL NOT NULL DEFAULT 0,
30
+ kb_is_faq_match INTEGER NOT NULL DEFAULT 0,
31
+ kb_top_chunk_content TEXT,
32
+ kb_result_count INTEGER NOT NULL DEFAULT 0,
33
+ status TEXT NOT NULL DEFAULT 'pending',
34
+ cluster_id TEXT,
35
+ times_asked INTEGER NOT NULL DEFAULT 1,
36
+ unique_customers TEXT NOT NULL DEFAULT '[]',
37
+ embedding BLOB,
38
+ suggested_answer TEXT,
39
+ taught_answer TEXT,
40
+ created_at INTEGER NOT NULL,
41
+ updated_at INTEGER NOT NULL
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_status ON copilot_queries(status);
45
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_normalized ON copilot_queries(normalized_query);
46
+ CREATE INDEX IF NOT EXISTS idx_copilot_queries_cluster ON copilot_queries(cluster_id);
47
+
48
+ CREATE TABLE IF NOT EXISTS copilot_clusters (
49
+ id TEXT PRIMARY KEY,
50
+ label TEXT,
51
+ representative_query TEXT NOT NULL,
52
+ centroid BLOB,
53
+ query_count INTEGER NOT NULL DEFAULT 1,
54
+ unique_customers TEXT NOT NULL DEFAULT '[]',
55
+ status TEXT NOT NULL DEFAULT 'pending',
56
+ created_at INTEGER NOT NULL,
57
+ updated_at INTEGER NOT NULL
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS idx_copilot_clusters_status ON copilot_clusters(status);
61
+
62
+ CREATE TABLE IF NOT EXISTS copilot_meta (
63
+ key TEXT PRIMARY KEY,
64
+ value TEXT NOT NULL
65
+ );
66
+ `);
67
+
68
+ // Virtual table for vector search on cluster centroids
69
+ this.db.exec(`
70
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_copilot_clusters USING vec0(
71
+ cluster_id TEXT PRIMARY KEY,
72
+ centroid float[${this.dimensions}]
73
+ );
74
+ `);
75
+ }
76
+
77
+ async close(): Promise<void> {
78
+ this.db.close();
79
+ }
80
+
81
+ // --- Queries ---
82
+
83
+ async addQuery(query: UnansweredQuery): Promise<void> {
84
+ this.db.prepare(`
85
+ INSERT OR REPLACE INTO copilot_queries
86
+ (id, query, normalized_query, channel, customer_phone, kb_top_score, kb_is_faq_match,
87
+ kb_top_chunk_content, kb_result_count, status, cluster_id, times_asked,
88
+ unique_customers, embedding, suggested_answer, taught_answer, created_at, updated_at)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ `).run(
91
+ query.id,
92
+ query.query,
93
+ query.normalizedQuery,
94
+ query.channel,
95
+ query.customerPhone,
96
+ query.kbTopScore,
97
+ query.kbIsFaqMatch ? 1 : 0,
98
+ query.kbTopChunkContent ?? null,
99
+ query.kbResultCount,
100
+ query.status,
101
+ query.clusterId ?? null,
102
+ query.timesAsked,
103
+ JSON.stringify(query.uniqueCustomers),
104
+ query.embedding ? Buffer.from(new Float32Array(query.embedding).buffer) : null,
105
+ query.suggestedAnswer ?? null,
106
+ query.taughtAnswer ?? null,
107
+ query.createdAt,
108
+ query.updatedAt,
109
+ );
110
+ }
111
+
112
+ async getQuery(id: string): Promise<UnansweredQuery | null> {
113
+ const row = this.db.prepare('SELECT * FROM copilot_queries WHERE id = ?').get(id) as any;
114
+ return row ? this.rowToQuery(row) : null;
115
+ }
116
+
117
+ async updateQuery(id: string, updates: Partial<UnansweredQuery>): Promise<void> {
118
+ const existing = await this.getQuery(id);
119
+ if (!existing) return;
120
+
121
+ const merged = { ...existing, ...updates, updatedAt: Date.now() };
122
+ await this.addQuery(merged);
123
+ }
124
+
125
+ async getPendingQueries(limit?: number): Promise<UnansweredQuery[]> {
126
+ const sql = limit
127
+ ? 'SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC LIMIT ?'
128
+ : 'SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC';
129
+ const rows = limit
130
+ ? (this.db.prepare(sql).all('pending', limit) as any[])
131
+ : (this.db.prepare(sql).all('pending') as any[]);
132
+ return rows.map(r => this.rowToQuery(r));
133
+ }
134
+
135
+ async findSimilarQuery(normalizedQuery: string): Promise<UnansweredQuery | null> {
136
+ const row = this.db.prepare(
137
+ 'SELECT * FROM copilot_queries WHERE normalized_query = ? AND status = ? LIMIT 1'
138
+ ).get(normalizedQuery, 'pending') as any;
139
+ return row ? this.rowToQuery(row) : null;
140
+ }
141
+
142
+ async getQueriesByCluster(clusterId: string): Promise<UnansweredQuery[]> {
143
+ const rows = this.db.prepare(
144
+ 'SELECT * FROM copilot_queries WHERE cluster_id = ? ORDER BY times_asked DESC'
145
+ ).all(clusterId) as any[];
146
+ return rows.map(r => this.rowToQuery(r));
147
+ }
148
+
149
+ // --- Clusters ---
150
+
151
+ async addCluster(cluster: QueryCluster): Promise<void> {
152
+ this.db.prepare(`
153
+ INSERT OR REPLACE INTO copilot_clusters
154
+ (id, label, representative_query, centroid, query_count, unique_customers, status, created_at, updated_at)
155
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
156
+ `).run(
157
+ cluster.id,
158
+ cluster.label ?? null,
159
+ cluster.representativeQuery,
160
+ cluster.centroid ? Buffer.from(new Float32Array(cluster.centroid).buffer) : null,
161
+ cluster.queryCount,
162
+ JSON.stringify(cluster.uniqueCustomers),
163
+ cluster.status,
164
+ cluster.createdAt,
165
+ cluster.updatedAt,
166
+ );
167
+
168
+ // Upsert into vector table for similarity search
169
+ if (cluster.centroid) {
170
+ this.db.prepare('DELETE FROM vec_copilot_clusters WHERE cluster_id = ?').run(cluster.id);
171
+ this.db.prepare(
172
+ 'INSERT INTO vec_copilot_clusters (cluster_id, centroid) VALUES (?, ?)'
173
+ ).run(cluster.id, new Float32Array(cluster.centroid));
174
+ }
175
+ }
176
+
177
+ async getCluster(id: string): Promise<QueryCluster | null> {
178
+ const row = this.db.prepare('SELECT * FROM copilot_clusters WHERE id = ?').get(id) as any;
179
+ return row ? this.rowToCluster(row) : null;
180
+ }
181
+
182
+ async updateCluster(id: string, updates: Partial<QueryCluster>): Promise<void> {
183
+ const existing = await this.getCluster(id);
184
+ if (!existing) return;
185
+
186
+ const merged = { ...existing, ...updates, updatedAt: Date.now() };
187
+ await this.addCluster(merged);
188
+ }
189
+
190
+ async getOpenClusters(): Promise<QueryCluster[]> {
191
+ const rows = this.db.prepare(
192
+ 'SELECT * FROM copilot_clusters WHERE status = ? ORDER BY query_count DESC'
193
+ ).all('pending') as any[];
194
+ return rows.map(r => this.rowToCluster(r));
195
+ }
196
+
197
+ /** Vector search cluster centroids — returns closest clusters by cosine distance */
198
+ async searchClusters(embedding: number[], limit: number): Promise<Array<{ clusterId: string; distance: number }>> {
199
+ const rows = this.db.prepare(`
200
+ SELECT cluster_id, distance
201
+ FROM vec_copilot_clusters
202
+ WHERE centroid MATCH ?
203
+ ORDER BY distance
204
+ LIMIT ?
205
+ `).all(new Float32Array(embedding), limit) as any[];
206
+
207
+ return rows.map(r => ({
208
+ clusterId: r.cluster_id as string,
209
+ distance: r.distance as number,
210
+ }));
211
+ }
212
+
213
+ // --- Metrics ---
214
+
215
+ async getImpactMetrics(topN = 10): Promise<ImpactMetrics> {
216
+ const counts = this.db.prepare(`
217
+ SELECT
218
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
219
+ SUM(CASE WHEN status = 'taught' THEN 1 ELSE 0 END) as taught_count,
220
+ SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,
221
+ SUM(times_asked) as total_times_asked
222
+ FROM copilot_queries
223
+ `).get() as any;
224
+
225
+ // Aggregate unique customers across all queries
226
+ const allCustomerRows = this.db.prepare(
227
+ 'SELECT unique_customers FROM copilot_queries'
228
+ ).all() as any[];
229
+ const allCustomers = new Set<string>();
230
+ for (const row of allCustomerRows) {
231
+ const customers: string[] = JSON.parse(row.unique_customers);
232
+ customers.forEach(c => allCustomers.add(c));
233
+ }
234
+
235
+ const topPending = await this.getPendingQueries(topN);
236
+
237
+ return {
238
+ pendingCount: counts.pending_count ?? 0,
239
+ taughtCount: counts.taught_count ?? 0,
240
+ dismissedCount: counts.dismissed_count ?? 0,
241
+ totalCustomersAffected: allCustomers.size,
242
+ totalTimesAsked: counts.total_times_asked ?? 0,
243
+ topPendingQueries: topPending,
244
+ };
245
+ }
246
+
247
+ // --- Digest tracking ---
248
+
249
+ async getLastDigestTime(): Promise<number> {
250
+ const row = this.db.prepare(
251
+ "SELECT value FROM copilot_meta WHERE key = 'last_digest_time'"
252
+ ).get() as any;
253
+ return row ? parseInt(row.value, 10) : 0;
254
+ }
255
+
256
+ async setLastDigestTime(time: number): Promise<void> {
257
+ this.db.prepare(
258
+ "INSERT OR REPLACE INTO copilot_meta (key, value) VALUES ('last_digest_time', ?)"
259
+ ).run(String(time));
260
+ }
261
+
262
+ // --- Row mappers ---
263
+
264
+ private rowToQuery(row: any): UnansweredQuery {
265
+ return {
266
+ id: row.id,
267
+ query: row.query,
268
+ normalizedQuery: row.normalized_query,
269
+ channel: row.channel,
270
+ customerPhone: row.customer_phone,
271
+ kbTopScore: row.kb_top_score,
272
+ kbIsFaqMatch: !!row.kb_is_faq_match,
273
+ kbTopChunkContent: row.kb_top_chunk_content ?? undefined,
274
+ kbResultCount: row.kb_result_count,
275
+ status: row.status,
276
+ clusterId: row.cluster_id ?? undefined,
277
+ timesAsked: row.times_asked,
278
+ uniqueCustomers: JSON.parse(row.unique_customers),
279
+ embedding: row.embedding ? Array.from(new Float32Array(row.embedding.buffer ?? row.embedding)) : undefined,
280
+ suggestedAnswer: row.suggested_answer ?? undefined,
281
+ taughtAnswer: row.taught_answer ?? undefined,
282
+ createdAt: row.created_at,
283
+ updatedAt: row.updated_at,
284
+ };
285
+ }
286
+
287
+ private rowToCluster(row: any): QueryCluster {
288
+ return {
289
+ id: row.id,
290
+ label: row.label ?? undefined,
291
+ representativeQuery: row.representative_query,
292
+ centroid: row.centroid ? Array.from(new Float32Array(row.centroid.buffer ?? row.centroid)) : undefined,
293
+ queryCount: row.query_count,
294
+ uniqueCustomers: JSON.parse(row.unique_customers),
295
+ status: row.status,
296
+ createdAt: row.created_at,
297
+ updatedAt: row.updated_at,
298
+ };
299
+ }
300
+ }
@@ -0,0 +1,44 @@
1
+ import type { AIProviderLike } from './types.js';
2
+ import type { KnowledgeBaseRuntime } from '@operor/core';
3
+
4
+ const SYSTEM_PROMPT = `You are a helpful assistant that drafts FAQ answers for a knowledge base. Write concise, accurate answers based on the available context. If the context doesn't contain enough information, say so.`;
5
+
6
+ /**
7
+ * Generates suggested answers for unanswered queries using the LLM + KB context.
8
+ */
9
+ export class SuggestionEngine {
10
+ constructor(
11
+ private aiProvider: AIProviderLike,
12
+ private kb: KnowledgeBaseRuntime,
13
+ ) {}
14
+
15
+ /**
16
+ * Generate a suggested answer for a query, optionally including related queries from a cluster.
17
+ */
18
+ async suggest(query: string, relatedQueries?: string[]): Promise<string> {
19
+ const kbResult = await this.kb.retrieve(query);
20
+
21
+ let prompt = `Customer query: "${query}"\n`;
22
+
23
+ if (relatedQueries && relatedQueries.length > 0) {
24
+ prompt += `\nRelated queries from other customers:\n`;
25
+ for (const rq of relatedQueries) {
26
+ prompt += `- "${rq}"\n`;
27
+ }
28
+ }
29
+
30
+ if (kbResult.context) {
31
+ prompt += `\nKnowledge base context:\n${kbResult.context}\n`;
32
+ }
33
+
34
+ prompt += `\nPlease draft a concise, helpful FAQ answer for this query.`;
35
+
36
+ const result = await this.aiProvider.generateText({
37
+ system: SYSTEM_PROMPT,
38
+ prompt,
39
+ temperature: 0.3,
40
+ });
41
+
42
+ return result.text;
43
+ }
44
+ }
@@ -0,0 +1,83 @@
1
+ import crypto from 'node:crypto';
2
+ import type { CopilotStore, EmbeddingService, MessageProcessedEvent, UnansweredQuery } from './types.js';
3
+ import type { QueryClusterer } from './QueryClusterer.js';
4
+
5
+ /**
6
+ * Tracks unanswered queries by listening to message:processed events.
7
+ * Runs non-blocking — errors are caught and logged, never thrown.
8
+ */
9
+ export class UnansweredQueryTracker {
10
+ constructor(
11
+ private store: CopilotStore,
12
+ private config: { enabled: boolean; trackingThreshold: number },
13
+ private embedder: EmbeddingService,
14
+ private clusterer?: QueryClusterer,
15
+ ) {}
16
+
17
+ /**
18
+ * Called after each message is processed. Decides whether to track the query.
19
+ * Non-blocking: catches all errors internally.
20
+ */
21
+ async maybeTrack(event: MessageProcessedEvent): Promise<void> {
22
+ try {
23
+ if (!this.config.enabled) return;
24
+
25
+ const kbTopScore = event.response.metadata?.kbTopScore ?? 0;
26
+ const kbResultCount = event.response.metadata?.kbResultCount ?? 0;
27
+
28
+ // If KB answered confidently, skip tracking
29
+ if (kbResultCount > 0 && kbTopScore >= this.config.trackingThreshold) return;
30
+
31
+ const normalizedQuery = this.normalizeQuery(event.query);
32
+
33
+ const existing = await this.store.findSimilarQuery(normalizedQuery);
34
+
35
+ if (existing) {
36
+ const uniqueCustomers = existing.uniqueCustomers.includes(event.customerPhone)
37
+ ? existing.uniqueCustomers
38
+ : [...existing.uniqueCustomers, event.customerPhone];
39
+
40
+ await this.store.updateQuery(existing.id, {
41
+ timesAsked: existing.timesAsked + 1,
42
+ uniqueCustomers,
43
+ updatedAt: Date.now(),
44
+ });
45
+ } else {
46
+ const embedding = await this.embedder.embed(normalizedQuery);
47
+ const now = Date.now();
48
+ const id = crypto.randomUUID();
49
+
50
+ const newQuery: UnansweredQuery = {
51
+ id,
52
+ query: event.query,
53
+ normalizedQuery,
54
+ channel: event.channel,
55
+ customerPhone: event.customerPhone,
56
+ kbTopScore,
57
+ kbIsFaqMatch: event.response.metadata?.kbIsFaqMatch ?? false,
58
+ kbTopChunkContent: event.response.metadata?.kbTopChunkContent,
59
+ kbResultCount,
60
+ status: 'pending',
61
+ timesAsked: 1,
62
+ uniqueCustomers: [event.customerPhone],
63
+ embedding,
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ };
67
+
68
+ await this.store.addQuery(newQuery);
69
+
70
+ if (this.clusterer) {
71
+ await this.clusterer.assignCluster(id, normalizedQuery, embedding);
72
+ }
73
+ }
74
+ } catch (_err) {
75
+ // Silently swallow — tracking must never disrupt the pipeline
76
+ }
77
+ }
78
+
79
+ /** Normalize query text for deduplication */
80
+ normalizeQuery(text: string): string {
81
+ return text.toLowerCase().trim().replace(/\s+/g, ' ');
82
+ }
83
+ }