@operor/copilot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +282 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +708 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -0
- package/src/CopilotCommandHandler.ts +263 -0
- package/src/DigestScheduler.ts +76 -0
- package/src/InMemoryCopilotStore.ts +90 -0
- package/src/QueryClusterer.ts +84 -0
- package/src/SQLiteCopilotStore.ts +300 -0
- package/src/SuggestionEngine.ts +44 -0
- package/src/UnansweredQueryTracker.ts +83 -0
- package/src/__tests__/copilot.test.ts +1007 -0
- package/src/index.ts +8 -0
- package/src/types.ts +131 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,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
|
+
}
|