@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 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/types.ts","../src/SQLiteCopilotStore.ts","../src/InMemoryCopilotStore.ts","../src/UnansweredQueryTracker.ts","../src/QueryClusterer.ts","../src/SuggestionEngine.ts","../src/CopilotCommandHandler.ts","../src/DigestScheduler.ts"],"sourcesContent":["/**\n * Training Copilot types\n */\n\n/** Status of an unanswered query through the review lifecycle */\nexport type QueryStatus = 'pending' | 'taught' | 'dismissed';\n\n/** A customer query that the KB couldn't confidently answer */\nexport interface UnansweredQuery {\n id: string;\n query: string;\n normalizedQuery: string;\n channel: string;\n customerPhone: string;\n kbTopScore: number;\n kbIsFaqMatch: boolean;\n kbTopChunkContent?: string;\n kbResultCount: number;\n status: QueryStatus;\n clusterId?: string;\n timesAsked: number;\n uniqueCustomers: string[];\n embedding?: number[];\n suggestedAnswer?: string;\n taughtAnswer?: string;\n createdAt: number;\n updatedAt: number;\n}\n\n/** A cluster of semantically similar unanswered queries */\nexport interface QueryCluster {\n id: string;\n label?: string;\n representativeQuery: string;\n centroid?: number[];\n queryCount: number;\n uniqueCustomers: string[];\n status: QueryStatus;\n createdAt: number;\n updatedAt: number;\n}\n\n/** Aggregate metrics for the copilot dashboard / digest */\nexport interface ImpactMetrics {\n pendingCount: number;\n taughtCount: number;\n dismissedCount: number;\n totalCustomersAffected: number;\n totalTimesAsked: number;\n topPendingQueries: UnansweredQuery[];\n}\n\n/** Configuration for the copilot subsystem */\nexport interface CopilotConfig {\n enabled: boolean;\n trackingThreshold: number;\n clusterThreshold: number;\n digestIntervalMs: number;\n digestMaxItems: number;\n autoSuggest: boolean;\n}\n\n/** Default copilot configuration values */\nexport const DEFAULT_COPILOT_CONFIG: CopilotConfig = {\n enabled: false,\n trackingThreshold: 0.70,\n clusterThreshold: 0.87,\n digestIntervalMs: 86_400_000, // 24h\n digestMaxItems: 10,\n autoSuggest: true,\n};\n\n/** Embedding service interface ā decouples copilot from specific embedding providers */\nexport interface EmbeddingService {\n embed(text: string): Promise<number[]>;\n dimensions: number;\n}\n\n/** AI provider interface for suggestion generation */\nexport interface AIProviderLike {\n generateText(options: {\n model?: string;\n system?: string;\n prompt: string;\n maxTokens?: number;\n temperature?: number;\n }): Promise<{ text: string }>;\n}\n\n/** Store interface for copilot persistence */\nexport interface CopilotStore {\n initialize(): Promise<void>;\n close(): Promise<void>;\n\n // Queries\n addQuery(query: UnansweredQuery): Promise<void>;\n getQuery(id: string): Promise<UnansweredQuery | null>;\n updateQuery(id: string, updates: Partial<UnansweredQuery>): Promise<void>;\n getPendingQueries(limit?: number): Promise<UnansweredQuery[]>;\n findSimilarQuery(normalizedQuery: string): Promise<UnansweredQuery | null>;\n getQueriesByCluster(clusterId: string): Promise<UnansweredQuery[]>;\n\n // Clusters\n addCluster(cluster: QueryCluster): Promise<void>;\n getCluster(id: string): Promise<QueryCluster | null>;\n updateCluster(id: string, updates: Partial<QueryCluster>): Promise<void>;\n getOpenClusters(): Promise<QueryCluster[]>;\n\n // Metrics\n getImpactMetrics(topN?: number): Promise<ImpactMetrics>;\n\n // Digest tracking\n getLastDigestTime(): Promise<number>;\n setLastDigestTime(time: number): Promise<void>;\n}\n\n/** Event emitted after a message is processed by the agent pipeline */\nexport interface MessageProcessedEvent {\n query: string;\n channel: string;\n customerPhone: string;\n response: {\n text: string;\n metadata?: {\n kbTopScore?: number;\n kbIsFaqMatch?: boolean;\n kbTopChunkContent?: string;\n kbResultCount?: number;\n };\n };\n}\n","import Database from 'better-sqlite3';\nimport * as sqliteVec from 'sqlite-vec';\nimport type { CopilotStore, UnansweredQuery, QueryCluster, ImpactMetrics } from './types.js';\n\n/**\n * SQLite-backed copilot store with sqlite-vec for vector search on cluster centroids.\n */\nexport class SQLiteCopilotStore implements CopilotStore {\n private db: Database.Database;\n\n constructor(\n private dbPath: string,\n private dimensions: number,\n ) {\n this.db = new Database(dbPath);\n this.db.pragma('journal_mode = WAL');\n this.db.pragma('foreign_keys = ON');\n sqliteVec.load(this.db);\n }\n\n async initialize(): Promise<void> {\n this.db.exec(`\n CREATE TABLE IF NOT EXISTS copilot_queries (\n id TEXT PRIMARY KEY,\n query TEXT NOT NULL,\n normalized_query TEXT NOT NULL,\n channel TEXT NOT NULL,\n customer_phone TEXT NOT NULL,\n kb_top_score REAL NOT NULL DEFAULT 0,\n kb_is_faq_match INTEGER NOT NULL DEFAULT 0,\n kb_top_chunk_content TEXT,\n kb_result_count INTEGER NOT NULL DEFAULT 0,\n status TEXT NOT NULL DEFAULT 'pending',\n cluster_id TEXT,\n times_asked INTEGER NOT NULL DEFAULT 1,\n unique_customers TEXT NOT NULL DEFAULT '[]',\n embedding BLOB,\n suggested_answer TEXT,\n taught_answer TEXT,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n );\n\n CREATE INDEX IF NOT EXISTS idx_copilot_queries_status ON copilot_queries(status);\n CREATE INDEX IF NOT EXISTS idx_copilot_queries_normalized ON copilot_queries(normalized_query);\n CREATE INDEX IF NOT EXISTS idx_copilot_queries_cluster ON copilot_queries(cluster_id);\n\n CREATE TABLE IF NOT EXISTS copilot_clusters (\n id TEXT PRIMARY KEY,\n label TEXT,\n representative_query TEXT NOT NULL,\n centroid BLOB,\n query_count INTEGER NOT NULL DEFAULT 1,\n unique_customers TEXT NOT NULL DEFAULT '[]',\n status TEXT NOT NULL DEFAULT 'pending',\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL\n );\n\n CREATE INDEX IF NOT EXISTS idx_copilot_clusters_status ON copilot_clusters(status);\n\n CREATE TABLE IF NOT EXISTS copilot_meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n `);\n\n // Virtual table for vector search on cluster centroids\n this.db.exec(`\n CREATE VIRTUAL TABLE IF NOT EXISTS vec_copilot_clusters USING vec0(\n cluster_id TEXT PRIMARY KEY,\n centroid float[${this.dimensions}]\n );\n `);\n }\n\n async close(): Promise<void> {\n this.db.close();\n }\n\n // --- Queries ---\n\n async addQuery(query: UnansweredQuery): Promise<void> {\n this.db.prepare(`\n INSERT OR REPLACE INTO copilot_queries\n (id, query, normalized_query, channel, customer_phone, kb_top_score, kb_is_faq_match,\n kb_top_chunk_content, kb_result_count, status, cluster_id, times_asked,\n unique_customers, embedding, suggested_answer, taught_answer, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n query.id,\n query.query,\n query.normalizedQuery,\n query.channel,\n query.customerPhone,\n query.kbTopScore,\n query.kbIsFaqMatch ? 1 : 0,\n query.kbTopChunkContent ?? null,\n query.kbResultCount,\n query.status,\n query.clusterId ?? null,\n query.timesAsked,\n JSON.stringify(query.uniqueCustomers),\n query.embedding ? Buffer.from(new Float32Array(query.embedding).buffer) : null,\n query.suggestedAnswer ?? null,\n query.taughtAnswer ?? null,\n query.createdAt,\n query.updatedAt,\n );\n }\n\n async getQuery(id: string): Promise<UnansweredQuery | null> {\n const row = this.db.prepare('SELECT * FROM copilot_queries WHERE id = ?').get(id) as any;\n return row ? this.rowToQuery(row) : null;\n }\n\n async updateQuery(id: string, updates: Partial<UnansweredQuery>): Promise<void> {\n const existing = await this.getQuery(id);\n if (!existing) return;\n\n const merged = { ...existing, ...updates, updatedAt: Date.now() };\n await this.addQuery(merged);\n }\n\n async getPendingQueries(limit?: number): Promise<UnansweredQuery[]> {\n const sql = limit\n ? 'SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC LIMIT ?'\n : 'SELECT * FROM copilot_queries WHERE status = ? ORDER BY times_asked DESC';\n const rows = limit\n ? (this.db.prepare(sql).all('pending', limit) as any[])\n : (this.db.prepare(sql).all('pending') as any[]);\n return rows.map(r => this.rowToQuery(r));\n }\n\n async findSimilarQuery(normalizedQuery: string): Promise<UnansweredQuery | null> {\n const row = this.db.prepare(\n 'SELECT * FROM copilot_queries WHERE normalized_query = ? AND status = ? LIMIT 1'\n ).get(normalizedQuery, 'pending') as any;\n return row ? this.rowToQuery(row) : null;\n }\n\n async getQueriesByCluster(clusterId: string): Promise<UnansweredQuery[]> {\n const rows = this.db.prepare(\n 'SELECT * FROM copilot_queries WHERE cluster_id = ? ORDER BY times_asked DESC'\n ).all(clusterId) as any[];\n return rows.map(r => this.rowToQuery(r));\n }\n\n // --- Clusters ---\n\n async addCluster(cluster: QueryCluster): Promise<void> {\n this.db.prepare(`\n INSERT OR REPLACE INTO copilot_clusters\n (id, label, representative_query, centroid, query_count, unique_customers, status, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n `).run(\n cluster.id,\n cluster.label ?? null,\n cluster.representativeQuery,\n cluster.centroid ? Buffer.from(new Float32Array(cluster.centroid).buffer) : null,\n cluster.queryCount,\n JSON.stringify(cluster.uniqueCustomers),\n cluster.status,\n cluster.createdAt,\n cluster.updatedAt,\n );\n\n // Upsert into vector table for similarity search\n if (cluster.centroid) {\n this.db.prepare('DELETE FROM vec_copilot_clusters WHERE cluster_id = ?').run(cluster.id);\n this.db.prepare(\n 'INSERT INTO vec_copilot_clusters (cluster_id, centroid) VALUES (?, ?)'\n ).run(cluster.id, new Float32Array(cluster.centroid));\n }\n }\n\n async getCluster(id: string): Promise<QueryCluster | null> {\n const row = this.db.prepare('SELECT * FROM copilot_clusters WHERE id = ?').get(id) as any;\n return row ? this.rowToCluster(row) : null;\n }\n\n async updateCluster(id: string, updates: Partial<QueryCluster>): Promise<void> {\n const existing = await this.getCluster(id);\n if (!existing) return;\n\n const merged = { ...existing, ...updates, updatedAt: Date.now() };\n await this.addCluster(merged);\n }\n\n async getOpenClusters(): Promise<QueryCluster[]> {\n const rows = this.db.prepare(\n 'SELECT * FROM copilot_clusters WHERE status = ? ORDER BY query_count DESC'\n ).all('pending') as any[];\n return rows.map(r => this.rowToCluster(r));\n }\n\n /** Vector search cluster centroids ā returns closest clusters by cosine distance */\n async searchClusters(embedding: number[], limit: number): Promise<Array<{ clusterId: string; distance: number }>> {\n const rows = this.db.prepare(`\n SELECT cluster_id, distance\n FROM vec_copilot_clusters\n WHERE centroid MATCH ?\n ORDER BY distance\n LIMIT ?\n `).all(new Float32Array(embedding), limit) as any[];\n\n return rows.map(r => ({\n clusterId: r.cluster_id as string,\n distance: r.distance as number,\n }));\n }\n\n // --- Metrics ---\n\n async getImpactMetrics(topN = 10): Promise<ImpactMetrics> {\n const counts = this.db.prepare(`\n SELECT\n SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,\n SUM(CASE WHEN status = 'taught' THEN 1 ELSE 0 END) as taught_count,\n SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END) as dismissed_count,\n SUM(times_asked) as total_times_asked\n FROM copilot_queries\n `).get() as any;\n\n // Aggregate unique customers across all queries\n const allCustomerRows = this.db.prepare(\n 'SELECT unique_customers FROM copilot_queries'\n ).all() as any[];\n const allCustomers = new Set<string>();\n for (const row of allCustomerRows) {\n const customers: string[] = JSON.parse(row.unique_customers);\n customers.forEach(c => allCustomers.add(c));\n }\n\n const topPending = await this.getPendingQueries(topN);\n\n return {\n pendingCount: counts.pending_count ?? 0,\n taughtCount: counts.taught_count ?? 0,\n dismissedCount: counts.dismissed_count ?? 0,\n totalCustomersAffected: allCustomers.size,\n totalTimesAsked: counts.total_times_asked ?? 0,\n topPendingQueries: topPending,\n };\n }\n\n // --- Digest tracking ---\n\n async getLastDigestTime(): Promise<number> {\n const row = this.db.prepare(\n \"SELECT value FROM copilot_meta WHERE key = 'last_digest_time'\"\n ).get() as any;\n return row ? parseInt(row.value, 10) : 0;\n }\n\n async setLastDigestTime(time: number): Promise<void> {\n this.db.prepare(\n \"INSERT OR REPLACE INTO copilot_meta (key, value) VALUES ('last_digest_time', ?)\"\n ).run(String(time));\n }\n\n // --- Row mappers ---\n\n private rowToQuery(row: any): UnansweredQuery {\n return {\n id: row.id,\n query: row.query,\n normalizedQuery: row.normalized_query,\n channel: row.channel,\n customerPhone: row.customer_phone,\n kbTopScore: row.kb_top_score,\n kbIsFaqMatch: !!row.kb_is_faq_match,\n kbTopChunkContent: row.kb_top_chunk_content ?? undefined,\n kbResultCount: row.kb_result_count,\n status: row.status,\n clusterId: row.cluster_id ?? undefined,\n timesAsked: row.times_asked,\n uniqueCustomers: JSON.parse(row.unique_customers),\n embedding: row.embedding ? Array.from(new Float32Array(row.embedding.buffer ?? row.embedding)) : undefined,\n suggestedAnswer: row.suggested_answer ?? undefined,\n taughtAnswer: row.taught_answer ?? undefined,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n }\n\n private rowToCluster(row: any): QueryCluster {\n return {\n id: row.id,\n label: row.label ?? undefined,\n representativeQuery: row.representative_query,\n centroid: row.centroid ? Array.from(new Float32Array(row.centroid.buffer ?? row.centroid)) : undefined,\n queryCount: row.query_count,\n uniqueCustomers: JSON.parse(row.unique_customers),\n status: row.status,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n };\n }\n}\n","import type { CopilotStore, UnansweredQuery, QueryCluster, ImpactMetrics } from './types.js';\n\n/**\n * In-memory copilot store for testing and development.\n */\nexport class InMemoryCopilotStore implements CopilotStore {\n private queries = new Map<string, UnansweredQuery>();\n private clusters = new Map<string, QueryCluster>();\n private lastDigestTime = 0;\n\n async initialize(): Promise<void> {}\n async close(): Promise<void> {}\n\n async addQuery(query: UnansweredQuery): Promise<void> {\n this.queries.set(query.id, { ...query });\n }\n\n async getQuery(id: string): Promise<UnansweredQuery | null> {\n return this.queries.get(id) ?? null;\n }\n\n async updateQuery(id: string, updates: Partial<UnansweredQuery>): Promise<void> {\n const q = this.queries.get(id);\n if (q) this.queries.set(id, { ...q, ...updates, updatedAt: Date.now() });\n }\n\n async getPendingQueries(limit?: number): Promise<UnansweredQuery[]> {\n const pending = [...this.queries.values()]\n .filter(q => q.status === 'pending')\n .sort((a, b) => b.timesAsked - a.timesAsked);\n return limit ? pending.slice(0, limit) : pending;\n }\n\n async findSimilarQuery(normalizedQuery: string): Promise<UnansweredQuery | null> {\n for (const q of this.queries.values()) {\n if (q.normalizedQuery === normalizedQuery) return q;\n }\n return null;\n }\n\n async getQueriesByCluster(clusterId: string): Promise<UnansweredQuery[]> {\n return [...this.queries.values()].filter(q => q.clusterId === clusterId);\n }\n\n async addCluster(cluster: QueryCluster): Promise<void> {\n this.clusters.set(cluster.id, { ...cluster });\n }\n\n async getCluster(id: string): Promise<QueryCluster | null> {\n return this.clusters.get(id) ?? null;\n }\n\n async updateCluster(id: string, updates: Partial<QueryCluster>): Promise<void> {\n const c = this.clusters.get(id);\n if (c) this.clusters.set(id, { ...c, ...updates, updatedAt: Date.now() });\n }\n\n async getOpenClusters(): Promise<QueryCluster[]> {\n return [...this.clusters.values()].filter(c => c.status === 'pending');\n }\n\n async getImpactMetrics(topN = 10): Promise<ImpactMetrics> {\n const all = [...this.queries.values()];\n const pending = all.filter(q => q.status === 'pending');\n const allCustomers = new Set<string>();\n let totalAsked = 0;\n for (const q of all) {\n q.uniqueCustomers.forEach(c => allCustomers.add(c));\n totalAsked += q.timesAsked;\n }\n return {\n pendingCount: pending.length,\n taughtCount: all.filter(q => q.status === 'taught').length,\n dismissedCount: all.filter(q => q.status === 'dismissed').length,\n totalCustomersAffected: allCustomers.size,\n totalTimesAsked: totalAsked,\n topPendingQueries: pending\n .sort((a, b) => b.timesAsked - a.timesAsked)\n .slice(0, topN),\n };\n }\n\n async getLastDigestTime(): Promise<number> {\n return this.lastDigestTime;\n }\n\n async setLastDigestTime(time: number): Promise<void> {\n this.lastDigestTime = time;\n }\n}\n","import crypto from 'node:crypto';\nimport type { CopilotStore, EmbeddingService, MessageProcessedEvent, UnansweredQuery } from './types.js';\nimport type { QueryClusterer } from './QueryClusterer.js';\n\n/**\n * Tracks unanswered queries by listening to message:processed events.\n * Runs non-blocking ā errors are caught and logged, never thrown.\n */\nexport class UnansweredQueryTracker {\n constructor(\n private store: CopilotStore,\n private config: { enabled: boolean; trackingThreshold: number },\n private embedder: EmbeddingService,\n private clusterer?: QueryClusterer,\n ) {}\n\n /**\n * Called after each message is processed. Decides whether to track the query.\n * Non-blocking: catches all errors internally.\n */\n async maybeTrack(event: MessageProcessedEvent): Promise<void> {\n try {\n if (!this.config.enabled) return;\n\n const kbTopScore = event.response.metadata?.kbTopScore ?? 0;\n const kbResultCount = event.response.metadata?.kbResultCount ?? 0;\n\n // If KB answered confidently, skip tracking\n if (kbResultCount > 0 && kbTopScore >= this.config.trackingThreshold) return;\n\n const normalizedQuery = this.normalizeQuery(event.query);\n\n const existing = await this.store.findSimilarQuery(normalizedQuery);\n\n if (existing) {\n const uniqueCustomers = existing.uniqueCustomers.includes(event.customerPhone)\n ? existing.uniqueCustomers\n : [...existing.uniqueCustomers, event.customerPhone];\n\n await this.store.updateQuery(existing.id, {\n timesAsked: existing.timesAsked + 1,\n uniqueCustomers,\n updatedAt: Date.now(),\n });\n } else {\n const embedding = await this.embedder.embed(normalizedQuery);\n const now = Date.now();\n const id = crypto.randomUUID();\n\n const newQuery: UnansweredQuery = {\n id,\n query: event.query,\n normalizedQuery,\n channel: event.channel,\n customerPhone: event.customerPhone,\n kbTopScore,\n kbIsFaqMatch: event.response.metadata?.kbIsFaqMatch ?? false,\n kbTopChunkContent: event.response.metadata?.kbTopChunkContent,\n kbResultCount,\n status: 'pending',\n timesAsked: 1,\n uniqueCustomers: [event.customerPhone],\n embedding,\n createdAt: now,\n updatedAt: now,\n };\n\n await this.store.addQuery(newQuery);\n\n if (this.clusterer) {\n await this.clusterer.assignCluster(id, normalizedQuery, embedding);\n }\n }\n } catch (_err) {\n // Silently swallow ā tracking must never disrupt the pipeline\n }\n }\n\n /** Normalize query text for deduplication */\n normalizeQuery(text: string): string {\n return text.toLowerCase().trim().replace(/\\s+/g, ' ');\n }\n}\n","import crypto from 'node:crypto';\nimport type { CopilotStore, EmbeddingService, QueryCluster } from './types.js';\n\n/**\n * Groups semantically similar unanswered queries into clusters.\n */\nexport class QueryClusterer {\n constructor(\n private store: CopilotStore,\n private embedder: EmbeddingService,\n private config: { clusterThreshold: number } = { clusterThreshold: 0.87 },\n ) {}\n\n /**\n * Assign a query to an existing cluster or create a new one.\n * Returns the cluster ID.\n */\n async assignCluster(queryId: string, queryText: string, embedding: number[]): Promise<string> {\n const clusters = await this.store.getOpenClusters();\n\n let bestCluster: QueryCluster | null = null;\n let bestScore = -1;\n\n for (const cluster of clusters) {\n if (!cluster.centroid) continue;\n const score = this.cosineSimilarity(embedding, cluster.centroid);\n if (score > bestScore) {\n bestScore = score;\n bestCluster = cluster;\n }\n }\n\n if (bestCluster && bestScore >= this.config.clusterThreshold) {\n const newCount = bestCluster.queryCount + 1;\n const newCentroid = this.updateCentroid(bestCluster.centroid!, embedding, newCount);\n\n await this.store.updateCluster(bestCluster.id, {\n centroid: newCentroid,\n queryCount: newCount,\n updatedAt: Date.now(),\n });\n\n await this.store.updateQuery(queryId, { clusterId: bestCluster.id });\n\n return bestCluster.id;\n }\n\n // Create a new cluster\n const clusterId = crypto.randomUUID();\n const now = Date.now();\n\n const newCluster: QueryCluster = {\n id: clusterId,\n representativeQuery: queryText,\n centroid: embedding,\n queryCount: 1,\n uniqueCustomers: [],\n status: 'pending',\n createdAt: now,\n updatedAt: now,\n };\n\n await this.store.addCluster(newCluster);\n await this.store.updateQuery(queryId, { clusterId });\n\n return clusterId;\n }\n\n /** Cosine similarity between two vectors */\n cosineSimilarity(a: number[], b: number[]): number {\n let dot = 0, magA = 0, magB = 0;\n for (let i = 0; i < a.length; i++) {\n dot += a[i] * b[i];\n magA += a[i] * a[i];\n magB += b[i] * b[i];\n }\n return dot / (Math.sqrt(magA) * Math.sqrt(magB));\n }\n\n /** Compute running average centroid */\n updateCentroid(current: number[], newVec: number[], count: number): number[] {\n return current.map((v, i) => (v * (count - 1) + newVec[i]) / count);\n }\n}\n","import type { AIProviderLike } from './types.js';\nimport type { KnowledgeBaseRuntime } from '@operor/core';\n\nconst 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.`;\n\n/**\n * Generates suggested answers for unanswered queries using the LLM + KB context.\n */\nexport class SuggestionEngine {\n constructor(\n private aiProvider: AIProviderLike,\n private kb: KnowledgeBaseRuntime,\n ) {}\n\n /**\n * Generate a suggested answer for a query, optionally including related queries from a cluster.\n */\n async suggest(query: string, relatedQueries?: string[]): Promise<string> {\n const kbResult = await this.kb.retrieve(query);\n\n let prompt = `Customer query: \"${query}\"\\n`;\n\n if (relatedQueries && relatedQueries.length > 0) {\n prompt += `\\nRelated queries from other customers:\\n`;\n for (const rq of relatedQueries) {\n prompt += `- \"${rq}\"\\n`;\n }\n }\n\n if (kbResult.context) {\n prompt += `\\nKnowledge base context:\\n${kbResult.context}\\n`;\n }\n\n prompt += `\\nPlease draft a concise, helpful FAQ answer for this query.`;\n\n const result = await this.aiProvider.generateText({\n system: SYSTEM_PROMPT,\n prompt,\n temperature: 0.3,\n });\n\n return result.text;\n }\n}\n","import type { CopilotStore, UnansweredQuery } from './types.js';\nimport type { KnowledgeBaseRuntime } from '@operor/core';\nimport type { SuggestionEngine } from './SuggestionEngine.js';\nimport type { QueryClusterer } from './QueryClusterer.js';\n\ninterface AdminSession {\n currentQuery: UnansweredQuery | null;\n}\n\n/**\n * Handles /review commands from admin users in training mode.\n */\nexport class CopilotCommandHandler {\n /** Per-admin session state: tracks which query they're currently reviewing */\n private sessions = new Map<string, AdminSession>();\n\n constructor(\n private store: CopilotStore,\n private suggestionEngine: SuggestionEngine | undefined,\n private kb: KnowledgeBaseRuntime,\n private clusterer?: QueryClusterer,\n ) {}\n\n /**\n * Handle a /review subcommand.\n * @param command The full command (always '/review')\n * @param args Subcommand + arguments (e.g. 'accept This is the answer')\n * @param adminPhone The admin's phone number\n * @param reply Function to send a reply back to the admin\n */\n async handleCommand(\n command: string,\n args: string,\n adminPhone: string,\n reply: (text: string) => Promise<void>,\n ): Promise<void> {\n const trimmed = args.trim();\n const spaceIndex = trimmed.indexOf(' ');\n const subcommand = spaceIndex === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIndex).toLowerCase();\n const subArgs = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();\n\n switch (subcommand) {\n case '':\n case 'next':\n return this.handleNext(adminPhone, reply);\n case 'accept':\n return this.handleAccept(adminPhone, subArgs, reply);\n case 'edit':\n return this.handleEdit(adminPhone, subArgs, reply);\n case 'skip':\n return this.handleSkip(adminPhone, reply);\n case 'stats':\n return this.handleStats(reply);\n case 'cluster':\n return this.handleCluster(adminPhone, reply);\n case 'help':\n return this.handleHelp(reply);\n default:\n await reply(`Unknown subcommand: *${subcommand}*\\n\\nType */review help* to see available commands.`);\n }\n }\n\n private getSession(adminPhone: string): AdminSession {\n let session = this.sessions.get(adminPhone);\n if (!session) {\n session = { currentQuery: null };\n this.sessions.set(adminPhone, session);\n }\n return session;\n }\n\n private async handleNext(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {\n const session = this.getSession(adminPhone);\n const pending = await this.store.getPendingQueries(1);\n\n if (pending.length === 0) {\n session.currentQuery = null;\n await reply('No pending queries to review. Great job! š');\n return;\n }\n\n const query = pending[0];\n session.currentQuery = query;\n\n let text = `*š Query for Review*\\n\\n`;\n text += `*Question:* ${query.query}\\n`;\n text += `*Times asked:* ${query.timesAsked}\\n`;\n text += `*Unique customers:* ${query.uniqueCustomers.length}\\n`;\n\n if (query.suggestedAnswer) {\n text += `\\n*š” Suggested answer:*\\n${query.suggestedAnswer}\\n`;\n } else if (this.suggestionEngine) {\n try {\n const suggested = await this.suggestionEngine.suggest(query.query);\n if (suggested) {\n query.suggestedAnswer = suggested;\n await this.store.updateQuery(query.id, { suggestedAnswer: suggested });\n text += `\\n*š” Suggested answer:*\\n${suggested}\\n`;\n }\n } catch {\n // Suggestion failed ā continue without it\n }\n }\n\n text += `\\n*Actions:*`;\n text += `\\n/review accept [answer] ā Teach this answer`;\n text += `\\n/review edit <answer> ā Provide your own answer`;\n text += `\\n/review skip ā Skip this query`;\n\n await reply(text);\n }\n\n private async handleAccept(\n adminPhone: string,\n answerText: string,\n reply: (text: string) => Promise<void>,\n ): Promise<void> {\n const session = this.getSession(adminPhone);\n const query = session.currentQuery;\n\n if (!query) {\n await reply('No query selected. Use */review next* to load one.');\n return;\n }\n\n const answer = answerText || query.suggestedAnswer;\n if (!answer) {\n await reply('No answer provided and no suggestion available. Use */review edit <answer>* to provide one.');\n return;\n }\n\n await this.kb.ingestFaq(query.query, answer);\n await this.store.updateQuery(query.id, {\n status: 'taught',\n taughtAnswer: answer,\n updatedAt: Date.now(),\n });\n\n session.currentQuery = null;\n\n await reply(`*ā
Taught!*\\n\\n*Q:* ${query.query}\\n*A:* ${answer}`);\n }\n\n private async handleEdit(\n adminPhone: string,\n answerText: string,\n reply: (text: string) => Promise<void>,\n ): Promise<void> {\n const session = this.getSession(adminPhone);\n const query = session.currentQuery;\n\n if (!query) {\n await reply('No query selected. Use */review next* to load one.');\n return;\n }\n\n if (!answerText) {\n await reply('Please provide an answer: */review edit <your answer>*');\n return;\n }\n\n await this.kb.ingestFaq(query.query, answerText);\n await this.store.updateQuery(query.id, {\n status: 'taught',\n taughtAnswer: answerText,\n updatedAt: Date.now(),\n });\n\n session.currentQuery = null;\n\n await reply(`*ā
Taught!*\\n\\n*Q:* ${query.query}\\n*A:* ${answerText}`);\n }\n\n private async handleSkip(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {\n const session = this.getSession(adminPhone);\n const query = session.currentQuery;\n\n if (!query) {\n await reply('No query selected. Use */review next* to load one.');\n return;\n }\n\n await this.store.updateQuery(query.id, {\n status: 'dismissed',\n updatedAt: Date.now(),\n });\n\n session.currentQuery = null;\n\n await reply(`*āļø Skipped:* ${query.query}`);\n }\n\n private async handleStats(reply: (text: string) => Promise<void>): Promise<void> {\n const metrics = await this.store.getImpactMetrics();\n\n let text = `*š Copilot Stats*\\n\\n`;\n text += `*Pending:* ${metrics.pendingCount}\\n`;\n text += `*Taught:* ${metrics.taughtCount}\\n`;\n text += `*Dismissed:* ${metrics.dismissedCount}\\n`;\n text += `*Customers affected:* ${metrics.totalCustomersAffected}\\n`;\n text += `*Total times asked:* ${metrics.totalTimesAsked}`;\n\n if (metrics.topPendingQueries.length > 0) {\n text += `\\n\\n*Top pending queries:*`;\n for (const q of metrics.topPendingQueries) {\n text += `\\n⢠${q.query} (Ć${q.timesAsked})`;\n }\n }\n\n await reply(text);\n }\n\n private async handleCluster(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {\n const session = this.getSession(adminPhone);\n const query = session.currentQuery;\n\n if (!query) {\n await reply('No query selected. Use */review next* to load one.');\n return;\n }\n\n if (!query.clusterId) {\n await reply('This query is not part of any cluster.');\n return;\n }\n\n const cluster = await this.store.getCluster(query.clusterId);\n if (!cluster) {\n await reply('Cluster not found.');\n return;\n }\n\n const queries = await this.store.getQueriesByCluster(query.clusterId);\n\n let text = `*š Cluster: ${cluster.label || cluster.representativeQuery}*\\n\\n`;\n text += `*Queries in cluster:* ${queries.length}\\n`;\n text += `*Unique customers:* ${cluster.uniqueCustomers.length}\\n\\n`;\n\n for (const q of queries) {\n const marker = q.id === query.id ? 'š ' : '⢠';\n text += `${marker}${q.query} (Ć${q.timesAsked})\\n`;\n }\n\n await reply(text);\n }\n\n private async handleHelp(reply: (text: string) => Promise<void>): Promise<void> {\n const text = [\n '*š Review Commands*',\n '',\n '*/review* ā Show next pending query',\n '*/review next* ā Same as above',\n '*/review accept [answer]* ā Teach with answer (or suggested)',\n '*/review edit <answer>* ā Teach with your own answer',\n '*/review skip* ā Dismiss current query',\n '*/review stats* ā Show impact metrics',\n '*/review cluster* ā Show related queries in cluster',\n '*/review help* ā This help message',\n ].join('\\n');\n\n await reply(text);\n }\n}\n","import type { CopilotStore } from './types.js';\n\n/**\n * Periodically sends digest summaries of unanswered queries to admin phones.\n */\nexport class DigestScheduler {\n private timer: ReturnType<typeof setInterval> | null = null;\n\n constructor(\n private store: CopilotStore,\n private config: { digestIntervalMs: number; digestMaxItems: number },\n private sendMessage: (phone: string, text: string) => Promise<void>,\n ) {}\n\n /** Start the digest scheduler for the given admin phones */\n start(adminPhones: string[]): void {\n if (this.timer) {\n clearInterval(this.timer);\n }\n\n this.timer = setInterval(async () => {\n try {\n const lastDigest = await this.store.getLastDigestTime();\n const now = Date.now();\n\n if (now - lastDigest < this.config.digestIntervalMs) {\n return;\n }\n\n const digest = await this.buildDigest();\n if (!digest) {\n return;\n }\n\n for (const phone of adminPhones) {\n await this.sendMessage(phone, digest);\n }\n\n await this.store.setLastDigestTime(now);\n } catch {\n // Digest tick failed ā will retry next interval\n }\n }, this.config.digestIntervalMs);\n }\n\n /** Stop the digest scheduler */\n stop(): void {\n if (this.timer) {\n clearInterval(this.timer);\n this.timer = null;\n }\n }\n\n /** Build the digest message text */\n async buildDigest(): Promise<string | null> {\n const metrics = await this.store.getImpactMetrics(this.config.digestMaxItems);\n\n if (metrics.pendingCount === 0) {\n return null;\n }\n\n let text = `*š¬ Training Copilot Digest*\\n\\n`;\n text += `*${metrics.pendingCount}* pending queries from *${metrics.totalCustomersAffected}* customers\\n`;\n\n if (metrics.topPendingQueries.length > 0) {\n text += `\\n*Top unanswered queries:*\\n`;\n for (const q of metrics.topPendingQueries) {\n text += `⢠${q.query} (Ć${q.timesAsked})\\n`;\n }\n }\n\n text += `\\nReply */review* to start reviewing`;\n\n return text;\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@operor/copilot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Training Copilot ā surfaces unanswered questions, clusters them, and lets admins teach the KB via chat",
|
|
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
|
+
"ai": "^6.0.0",
|
|
16
|
+
"better-sqlite3": "^12.0.0",
|
|
17
|
+
"sqlite-vec": "^0.1.7-alpha.2",
|
|
18
|
+
"@operor/core": "0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"tsdown": "^0.20.3",
|
|
24
|
+
"typescript": "^5.7.0",
|
|
25
|
+
"vitest": "^4.0.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsdown",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { CopilotStore, UnansweredQuery } from './types.js';
|
|
2
|
+
import type { KnowledgeBaseRuntime } from '@operor/core';
|
|
3
|
+
import type { SuggestionEngine } from './SuggestionEngine.js';
|
|
4
|
+
import type { QueryClusterer } from './QueryClusterer.js';
|
|
5
|
+
|
|
6
|
+
interface AdminSession {
|
|
7
|
+
currentQuery: UnansweredQuery | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handles /review commands from admin users in training mode.
|
|
12
|
+
*/
|
|
13
|
+
export class CopilotCommandHandler {
|
|
14
|
+
/** Per-admin session state: tracks which query they're currently reviewing */
|
|
15
|
+
private sessions = new Map<string, AdminSession>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private store: CopilotStore,
|
|
19
|
+
private suggestionEngine: SuggestionEngine | undefined,
|
|
20
|
+
private kb: KnowledgeBaseRuntime,
|
|
21
|
+
private clusterer?: QueryClusterer,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handle a /review subcommand.
|
|
26
|
+
* @param command The full command (always '/review')
|
|
27
|
+
* @param args Subcommand + arguments (e.g. 'accept This is the answer')
|
|
28
|
+
* @param adminPhone The admin's phone number
|
|
29
|
+
* @param reply Function to send a reply back to the admin
|
|
30
|
+
*/
|
|
31
|
+
async handleCommand(
|
|
32
|
+
command: string,
|
|
33
|
+
args: string,
|
|
34
|
+
adminPhone: string,
|
|
35
|
+
reply: (text: string) => Promise<void>,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const trimmed = args.trim();
|
|
38
|
+
const spaceIndex = trimmed.indexOf(' ');
|
|
39
|
+
const subcommand = spaceIndex === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIndex).toLowerCase();
|
|
40
|
+
const subArgs = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
|
41
|
+
|
|
42
|
+
switch (subcommand) {
|
|
43
|
+
case '':
|
|
44
|
+
case 'next':
|
|
45
|
+
return this.handleNext(adminPhone, reply);
|
|
46
|
+
case 'accept':
|
|
47
|
+
return this.handleAccept(adminPhone, subArgs, reply);
|
|
48
|
+
case 'edit':
|
|
49
|
+
return this.handleEdit(adminPhone, subArgs, reply);
|
|
50
|
+
case 'skip':
|
|
51
|
+
return this.handleSkip(adminPhone, reply);
|
|
52
|
+
case 'stats':
|
|
53
|
+
return this.handleStats(reply);
|
|
54
|
+
case 'cluster':
|
|
55
|
+
return this.handleCluster(adminPhone, reply);
|
|
56
|
+
case 'help':
|
|
57
|
+
return this.handleHelp(reply);
|
|
58
|
+
default:
|
|
59
|
+
await reply(`Unknown subcommand: *${subcommand}*\n\nType */review help* to see available commands.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private getSession(adminPhone: string): AdminSession {
|
|
64
|
+
let session = this.sessions.get(adminPhone);
|
|
65
|
+
if (!session) {
|
|
66
|
+
session = { currentQuery: null };
|
|
67
|
+
this.sessions.set(adminPhone, session);
|
|
68
|
+
}
|
|
69
|
+
return session;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async handleNext(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {
|
|
73
|
+
const session = this.getSession(adminPhone);
|
|
74
|
+
const pending = await this.store.getPendingQueries(1);
|
|
75
|
+
|
|
76
|
+
if (pending.length === 0) {
|
|
77
|
+
session.currentQuery = null;
|
|
78
|
+
await reply('No pending queries to review. Great job! š');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const query = pending[0];
|
|
83
|
+
session.currentQuery = query;
|
|
84
|
+
|
|
85
|
+
let text = `*š Query for Review*\n\n`;
|
|
86
|
+
text += `*Question:* ${query.query}\n`;
|
|
87
|
+
text += `*Times asked:* ${query.timesAsked}\n`;
|
|
88
|
+
text += `*Unique customers:* ${query.uniqueCustomers.length}\n`;
|
|
89
|
+
|
|
90
|
+
if (query.suggestedAnswer) {
|
|
91
|
+
text += `\n*š” Suggested answer:*\n${query.suggestedAnswer}\n`;
|
|
92
|
+
} else if (this.suggestionEngine) {
|
|
93
|
+
try {
|
|
94
|
+
const suggested = await this.suggestionEngine.suggest(query.query);
|
|
95
|
+
if (suggested) {
|
|
96
|
+
query.suggestedAnswer = suggested;
|
|
97
|
+
await this.store.updateQuery(query.id, { suggestedAnswer: suggested });
|
|
98
|
+
text += `\n*š” Suggested answer:*\n${suggested}\n`;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Suggestion failed ā continue without it
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
text += `\n*Actions:*`;
|
|
106
|
+
text += `\n/review accept [answer] ā Teach this answer`;
|
|
107
|
+
text += `\n/review edit <answer> ā Provide your own answer`;
|
|
108
|
+
text += `\n/review skip ā Skip this query`;
|
|
109
|
+
|
|
110
|
+
await reply(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async handleAccept(
|
|
114
|
+
adminPhone: string,
|
|
115
|
+
answerText: string,
|
|
116
|
+
reply: (text: string) => Promise<void>,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const session = this.getSession(adminPhone);
|
|
119
|
+
const query = session.currentQuery;
|
|
120
|
+
|
|
121
|
+
if (!query) {
|
|
122
|
+
await reply('No query selected. Use */review next* to load one.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const answer = answerText || query.suggestedAnswer;
|
|
127
|
+
if (!answer) {
|
|
128
|
+
await reply('No answer provided and no suggestion available. Use */review edit <answer>* to provide one.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await this.kb.ingestFaq(query.query, answer);
|
|
133
|
+
await this.store.updateQuery(query.id, {
|
|
134
|
+
status: 'taught',
|
|
135
|
+
taughtAnswer: answer,
|
|
136
|
+
updatedAt: Date.now(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
session.currentQuery = null;
|
|
140
|
+
|
|
141
|
+
await reply(`*ā
Taught!*\n\n*Q:* ${query.query}\n*A:* ${answer}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async handleEdit(
|
|
145
|
+
adminPhone: string,
|
|
146
|
+
answerText: string,
|
|
147
|
+
reply: (text: string) => Promise<void>,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
const session = this.getSession(adminPhone);
|
|
150
|
+
const query = session.currentQuery;
|
|
151
|
+
|
|
152
|
+
if (!query) {
|
|
153
|
+
await reply('No query selected. Use */review next* to load one.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!answerText) {
|
|
158
|
+
await reply('Please provide an answer: */review edit <your answer>*');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await this.kb.ingestFaq(query.query, answerText);
|
|
163
|
+
await this.store.updateQuery(query.id, {
|
|
164
|
+
status: 'taught',
|
|
165
|
+
taughtAnswer: answerText,
|
|
166
|
+
updatedAt: Date.now(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
session.currentQuery = null;
|
|
170
|
+
|
|
171
|
+
await reply(`*ā
Taught!*\n\n*Q:* ${query.query}\n*A:* ${answerText}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async handleSkip(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {
|
|
175
|
+
const session = this.getSession(adminPhone);
|
|
176
|
+
const query = session.currentQuery;
|
|
177
|
+
|
|
178
|
+
if (!query) {
|
|
179
|
+
await reply('No query selected. Use */review next* to load one.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await this.store.updateQuery(query.id, {
|
|
184
|
+
status: 'dismissed',
|
|
185
|
+
updatedAt: Date.now(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
session.currentQuery = null;
|
|
189
|
+
|
|
190
|
+
await reply(`*āļø Skipped:* ${query.query}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async handleStats(reply: (text: string) => Promise<void>): Promise<void> {
|
|
194
|
+
const metrics = await this.store.getImpactMetrics();
|
|
195
|
+
|
|
196
|
+
let text = `*š Copilot Stats*\n\n`;
|
|
197
|
+
text += `*Pending:* ${metrics.pendingCount}\n`;
|
|
198
|
+
text += `*Taught:* ${metrics.taughtCount}\n`;
|
|
199
|
+
text += `*Dismissed:* ${metrics.dismissedCount}\n`;
|
|
200
|
+
text += `*Customers affected:* ${metrics.totalCustomersAffected}\n`;
|
|
201
|
+
text += `*Total times asked:* ${metrics.totalTimesAsked}`;
|
|
202
|
+
|
|
203
|
+
if (metrics.topPendingQueries.length > 0) {
|
|
204
|
+
text += `\n\n*Top pending queries:*`;
|
|
205
|
+
for (const q of metrics.topPendingQueries) {
|
|
206
|
+
text += `\n⢠${q.query} (Ć${q.timesAsked})`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await reply(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async handleCluster(adminPhone: string, reply: (text: string) => Promise<void>): Promise<void> {
|
|
214
|
+
const session = this.getSession(adminPhone);
|
|
215
|
+
const query = session.currentQuery;
|
|
216
|
+
|
|
217
|
+
if (!query) {
|
|
218
|
+
await reply('No query selected. Use */review next* to load one.');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!query.clusterId) {
|
|
223
|
+
await reply('This query is not part of any cluster.');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const cluster = await this.store.getCluster(query.clusterId);
|
|
228
|
+
if (!cluster) {
|
|
229
|
+
await reply('Cluster not found.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const queries = await this.store.getQueriesByCluster(query.clusterId);
|
|
234
|
+
|
|
235
|
+
let text = `*š Cluster: ${cluster.label || cluster.representativeQuery}*\n\n`;
|
|
236
|
+
text += `*Queries in cluster:* ${queries.length}\n`;
|
|
237
|
+
text += `*Unique customers:* ${cluster.uniqueCustomers.length}\n\n`;
|
|
238
|
+
|
|
239
|
+
for (const q of queries) {
|
|
240
|
+
const marker = q.id === query.id ? 'š ' : '⢠';
|
|
241
|
+
text += `${marker}${q.query} (Ć${q.timesAsked})\n`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await reply(text);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async handleHelp(reply: (text: string) => Promise<void>): Promise<void> {
|
|
248
|
+
const text = [
|
|
249
|
+
'*š Review Commands*',
|
|
250
|
+
'',
|
|
251
|
+
'*/review* ā Show next pending query',
|
|
252
|
+
'*/review next* ā Same as above',
|
|
253
|
+
'*/review accept [answer]* ā Teach with answer (or suggested)',
|
|
254
|
+
'*/review edit <answer>* ā Teach with your own answer',
|
|
255
|
+
'*/review skip* ā Dismiss current query',
|
|
256
|
+
'*/review stats* ā Show impact metrics',
|
|
257
|
+
'*/review cluster* ā Show related queries in cluster',
|
|
258
|
+
'*/review help* ā This help message',
|
|
259
|
+
].join('\n');
|
|
260
|
+
|
|
261
|
+
await reply(text);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CopilotStore } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Periodically sends digest summaries of unanswered queries to admin phones.
|
|
5
|
+
*/
|
|
6
|
+
export class DigestScheduler {
|
|
7
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
private store: CopilotStore,
|
|
11
|
+
private config: { digestIntervalMs: number; digestMaxItems: number },
|
|
12
|
+
private sendMessage: (phone: string, text: string) => Promise<void>,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
/** Start the digest scheduler for the given admin phones */
|
|
16
|
+
start(adminPhones: string[]): void {
|
|
17
|
+
if (this.timer) {
|
|
18
|
+
clearInterval(this.timer);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.timer = setInterval(async () => {
|
|
22
|
+
try {
|
|
23
|
+
const lastDigest = await this.store.getLastDigestTime();
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
|
|
26
|
+
if (now - lastDigest < this.config.digestIntervalMs) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const digest = await this.buildDigest();
|
|
31
|
+
if (!digest) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const phone of adminPhones) {
|
|
36
|
+
await this.sendMessage(phone, digest);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await this.store.setLastDigestTime(now);
|
|
40
|
+
} catch {
|
|
41
|
+
// Digest tick failed ā will retry next interval
|
|
42
|
+
}
|
|
43
|
+
}, this.config.digestIntervalMs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Stop the digest scheduler */
|
|
47
|
+
stop(): void {
|
|
48
|
+
if (this.timer) {
|
|
49
|
+
clearInterval(this.timer);
|
|
50
|
+
this.timer = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the digest message text */
|
|
55
|
+
async buildDigest(): Promise<string | null> {
|
|
56
|
+
const metrics = await this.store.getImpactMetrics(this.config.digestMaxItems);
|
|
57
|
+
|
|
58
|
+
if (metrics.pendingCount === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let text = `*š¬ Training Copilot Digest*\n\n`;
|
|
63
|
+
text += `*${metrics.pendingCount}* pending queries from *${metrics.totalCustomersAffected}* customers\n`;
|
|
64
|
+
|
|
65
|
+
if (metrics.topPendingQueries.length > 0) {
|
|
66
|
+
text += `\n*Top unanswered queries:*\n`;
|
|
67
|
+
for (const q of metrics.topPendingQueries) {
|
|
68
|
+
text += `⢠${q.query} (Ć${q.timesAsked})\n`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
text += `\nReply */review* to start reviewing`;
|
|
73
|
+
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { CopilotStore, UnansweredQuery, QueryCluster, ImpactMetrics } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory copilot store for testing and development.
|
|
5
|
+
*/
|
|
6
|
+
export class InMemoryCopilotStore implements CopilotStore {
|
|
7
|
+
private queries = new Map<string, UnansweredQuery>();
|
|
8
|
+
private clusters = new Map<string, QueryCluster>();
|
|
9
|
+
private lastDigestTime = 0;
|
|
10
|
+
|
|
11
|
+
async initialize(): Promise<void> {}
|
|
12
|
+
async close(): Promise<void> {}
|
|
13
|
+
|
|
14
|
+
async addQuery(query: UnansweredQuery): Promise<void> {
|
|
15
|
+
this.queries.set(query.id, { ...query });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getQuery(id: string): Promise<UnansweredQuery | null> {
|
|
19
|
+
return this.queries.get(id) ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async updateQuery(id: string, updates: Partial<UnansweredQuery>): Promise<void> {
|
|
23
|
+
const q = this.queries.get(id);
|
|
24
|
+
if (q) this.queries.set(id, { ...q, ...updates, updatedAt: Date.now() });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getPendingQueries(limit?: number): Promise<UnansweredQuery[]> {
|
|
28
|
+
const pending = [...this.queries.values()]
|
|
29
|
+
.filter(q => q.status === 'pending')
|
|
30
|
+
.sort((a, b) => b.timesAsked - a.timesAsked);
|
|
31
|
+
return limit ? pending.slice(0, limit) : pending;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async findSimilarQuery(normalizedQuery: string): Promise<UnansweredQuery | null> {
|
|
35
|
+
for (const q of this.queries.values()) {
|
|
36
|
+
if (q.normalizedQuery === normalizedQuery) return q;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getQueriesByCluster(clusterId: string): Promise<UnansweredQuery[]> {
|
|
42
|
+
return [...this.queries.values()].filter(q => q.clusterId === clusterId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async addCluster(cluster: QueryCluster): Promise<void> {
|
|
46
|
+
this.clusters.set(cluster.id, { ...cluster });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getCluster(id: string): Promise<QueryCluster | null> {
|
|
50
|
+
return this.clusters.get(id) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async updateCluster(id: string, updates: Partial<QueryCluster>): Promise<void> {
|
|
54
|
+
const c = this.clusters.get(id);
|
|
55
|
+
if (c) this.clusters.set(id, { ...c, ...updates, updatedAt: Date.now() });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getOpenClusters(): Promise<QueryCluster[]> {
|
|
59
|
+
return [...this.clusters.values()].filter(c => c.status === 'pending');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getImpactMetrics(topN = 10): Promise<ImpactMetrics> {
|
|
63
|
+
const all = [...this.queries.values()];
|
|
64
|
+
const pending = all.filter(q => q.status === 'pending');
|
|
65
|
+
const allCustomers = new Set<string>();
|
|
66
|
+
let totalAsked = 0;
|
|
67
|
+
for (const q of all) {
|
|
68
|
+
q.uniqueCustomers.forEach(c => allCustomers.add(c));
|
|
69
|
+
totalAsked += q.timesAsked;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
pendingCount: pending.length,
|
|
73
|
+
taughtCount: all.filter(q => q.status === 'taught').length,
|
|
74
|
+
dismissedCount: all.filter(q => q.status === 'dismissed').length,
|
|
75
|
+
totalCustomersAffected: allCustomers.size,
|
|
76
|
+
totalTimesAsked: totalAsked,
|
|
77
|
+
topPendingQueries: pending
|
|
78
|
+
.sort((a, b) => b.timesAsked - a.timesAsked)
|
|
79
|
+
.slice(0, topN),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getLastDigestTime(): Promise<number> {
|
|
84
|
+
return this.lastDigestTime;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async setLastDigestTime(time: number): Promise<void> {
|
|
88
|
+
this.lastDigestTime = time;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import type { CopilotStore, EmbeddingService, QueryCluster } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Groups semantically similar unanswered queries into clusters.
|
|
6
|
+
*/
|
|
7
|
+
export class QueryClusterer {
|
|
8
|
+
constructor(
|
|
9
|
+
private store: CopilotStore,
|
|
10
|
+
private embedder: EmbeddingService,
|
|
11
|
+
private config: { clusterThreshold: number } = { clusterThreshold: 0.87 },
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Assign a query to an existing cluster or create a new one.
|
|
16
|
+
* Returns the cluster ID.
|
|
17
|
+
*/
|
|
18
|
+
async assignCluster(queryId: string, queryText: string, embedding: number[]): Promise<string> {
|
|
19
|
+
const clusters = await this.store.getOpenClusters();
|
|
20
|
+
|
|
21
|
+
let bestCluster: QueryCluster | null = null;
|
|
22
|
+
let bestScore = -1;
|
|
23
|
+
|
|
24
|
+
for (const cluster of clusters) {
|
|
25
|
+
if (!cluster.centroid) continue;
|
|
26
|
+
const score = this.cosineSimilarity(embedding, cluster.centroid);
|
|
27
|
+
if (score > bestScore) {
|
|
28
|
+
bestScore = score;
|
|
29
|
+
bestCluster = cluster;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (bestCluster && bestScore >= this.config.clusterThreshold) {
|
|
34
|
+
const newCount = bestCluster.queryCount + 1;
|
|
35
|
+
const newCentroid = this.updateCentroid(bestCluster.centroid!, embedding, newCount);
|
|
36
|
+
|
|
37
|
+
await this.store.updateCluster(bestCluster.id, {
|
|
38
|
+
centroid: newCentroid,
|
|
39
|
+
queryCount: newCount,
|
|
40
|
+
updatedAt: Date.now(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await this.store.updateQuery(queryId, { clusterId: bestCluster.id });
|
|
44
|
+
|
|
45
|
+
return bestCluster.id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Create a new cluster
|
|
49
|
+
const clusterId = crypto.randomUUID();
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
|
|
52
|
+
const newCluster: QueryCluster = {
|
|
53
|
+
id: clusterId,
|
|
54
|
+
representativeQuery: queryText,
|
|
55
|
+
centroid: embedding,
|
|
56
|
+
queryCount: 1,
|
|
57
|
+
uniqueCustomers: [],
|
|
58
|
+
status: 'pending',
|
|
59
|
+
createdAt: now,
|
|
60
|
+
updatedAt: now,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await this.store.addCluster(newCluster);
|
|
64
|
+
await this.store.updateQuery(queryId, { clusterId });
|
|
65
|
+
|
|
66
|
+
return clusterId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cosine similarity between two vectors */
|
|
70
|
+
cosineSimilarity(a: number[], b: number[]): number {
|
|
71
|
+
let dot = 0, magA = 0, magB = 0;
|
|
72
|
+
for (let i = 0; i < a.length; i++) {
|
|
73
|
+
dot += a[i] * b[i];
|
|
74
|
+
magA += a[i] * a[i];
|
|
75
|
+
magB += b[i] * b[i];
|
|
76
|
+
}
|
|
77
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Compute running average centroid */
|
|
81
|
+
updateCentroid(current: number[], newVec: number[], count: number): number[] {
|
|
82
|
+
return current.map((v, i) => (v * (count - 1) + newVec[i]) / count);
|
|
83
|
+
}
|
|
84
|
+
}
|