semantic-router-ts 1.0.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.js ADDED
@@ -0,0 +1,467 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ BaseEncoder: () => BaseEncoder,
34
+ LocalEncoder: () => LocalEncoder,
35
+ LocalIndex: () => LocalIndex,
36
+ OpenAIEncoder: () => OpenAIEncoder,
37
+ SemanticRouter: () => SemanticRouter
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/encoders/base.ts
42
+ var BaseEncoder = class {
43
+ initialized = false;
44
+ /**
45
+ * Encode multiple texts in batch.
46
+ * Default implementation calls encode() for each, but subclasses
47
+ * can override for more efficient batch processing.
48
+ */
49
+ async encodeBatch(texts) {
50
+ return Promise.all(texts.map((text) => this.encode(text)));
51
+ }
52
+ /**
53
+ * Normalize an embedding vector to unit length.
54
+ */
55
+ normalize(embedding) {
56
+ const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
57
+ if (norm === 0) return embedding;
58
+ return embedding.map((val) => val / norm);
59
+ }
60
+ };
61
+
62
+ // src/encoders/local.ts
63
+ var pipeline = null;
64
+ var loadPromise = null;
65
+ async function loadTransformers() {
66
+ if (pipeline) return;
67
+ if (loadPromise) {
68
+ await loadPromise;
69
+ return;
70
+ }
71
+ loadPromise = (async () => {
72
+ try {
73
+ const transformers = await import("@xenova/transformers");
74
+ pipeline = transformers.pipeline;
75
+ } catch {
76
+ throw new Error(
77
+ "LocalEncoder requires @xenova/transformers. Install it with: npm install @xenova/transformers"
78
+ );
79
+ }
80
+ })();
81
+ await loadPromise;
82
+ }
83
+ var LocalEncoder = class _LocalEncoder extends BaseEncoder {
84
+ name = "LocalEncoder";
85
+ dimensions;
86
+ model;
87
+ embedder = null;
88
+ initPromise = null;
89
+ shouldNormalize;
90
+ // Model -> dimensions mapping
91
+ static MODEL_DIMENSIONS = {
92
+ "Xenova/all-MiniLM-L6-v2": 384,
93
+ "Xenova/all-mpnet-base-v2": 768,
94
+ "Xenova/bge-small-en-v1.5": 384,
95
+ "Xenova/bge-base-en-v1.5": 768
96
+ };
97
+ constructor(config = {}) {
98
+ super();
99
+ this.model = config.model || "Xenova/all-MiniLM-L6-v2";
100
+ this.dimensions = _LocalEncoder.MODEL_DIMENSIONS[this.model] || 384;
101
+ this.shouldNormalize = config.normalize ?? true;
102
+ }
103
+ async ensureInitialized() {
104
+ if (this.initialized && this.embedder) return;
105
+ if (this.initPromise) {
106
+ await this.initPromise;
107
+ return;
108
+ }
109
+ this.initPromise = this.initialize();
110
+ await this.initPromise;
111
+ }
112
+ async initialize() {
113
+ await loadTransformers();
114
+ console.log(`[LocalEncoder] Loading model: ${this.model}...`);
115
+ const start = Date.now();
116
+ this.embedder = await pipeline("feature-extraction", this.model);
117
+ this.initialized = true;
118
+ console.log(`[LocalEncoder] Model loaded in ${Date.now() - start}ms`);
119
+ }
120
+ async encode(text) {
121
+ await this.ensureInitialized();
122
+ const result = await this.embedder(text, {
123
+ pooling: "mean",
124
+ normalize: this.shouldNormalize
125
+ });
126
+ return Array.from(result.data);
127
+ }
128
+ async encodeBatch(texts) {
129
+ await this.ensureInitialized();
130
+ const results = await Promise.all(
131
+ texts.map(async (text) => {
132
+ const result = await this.embedder(text, {
133
+ pooling: "mean",
134
+ normalize: this.shouldNormalize
135
+ });
136
+ return Array.from(result.data);
137
+ })
138
+ );
139
+ return results;
140
+ }
141
+ };
142
+
143
+ // src/encoders/openai.ts
144
+ var OpenAIClass = null;
145
+ async function loadOpenAI() {
146
+ if (OpenAIClass) return;
147
+ try {
148
+ const openaiModule = await import("openai");
149
+ OpenAIClass = openaiModule.default || openaiModule.OpenAI;
150
+ } catch {
151
+ throw new Error(
152
+ "OpenAIEncoder requires openai package. Install it with: npm install openai"
153
+ );
154
+ }
155
+ }
156
+ var OpenAIEncoder = class _OpenAIEncoder extends BaseEncoder {
157
+ name = "OpenAIEncoder";
158
+ dimensions;
159
+ model;
160
+ apiKey;
161
+ client = null;
162
+ // Model -> default dimensions mapping
163
+ static MODEL_DIMENSIONS = {
164
+ "text-embedding-3-small": 1536,
165
+ "text-embedding-3-large": 3072,
166
+ "text-embedding-ada-002": 1536
167
+ };
168
+ constructor(config = {}) {
169
+ super();
170
+ this.model = config.model || "text-embedding-3-small";
171
+ this.dimensions = config.dimensions || _OpenAIEncoder.MODEL_DIMENSIONS[this.model] || 1536;
172
+ this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || "";
173
+ if (!this.apiKey) {
174
+ console.warn("[OpenAIEncoder] No API key provided. Set OPENAI_API_KEY env var or pass apiKey config.");
175
+ }
176
+ }
177
+ async ensureClient() {
178
+ if (this.client) return;
179
+ await loadOpenAI();
180
+ this.client = new OpenAIClass({ apiKey: this.apiKey });
181
+ this.initialized = true;
182
+ }
183
+ async encode(text) {
184
+ const results = await this.encodeBatch([text]);
185
+ return results[0];
186
+ }
187
+ async encodeBatch(texts) {
188
+ await this.ensureClient();
189
+ const response = await this.client.embeddings.create({
190
+ model: this.model,
191
+ input: texts,
192
+ ...this.model.includes("3-") && { dimensions: this.dimensions }
193
+ });
194
+ const sorted = response.data.sort((a, b) => a.index - b.index);
195
+ return sorted.map((item) => item.embedding);
196
+ }
197
+ };
198
+
199
+ // src/index/local.ts
200
+ var LocalIndex = class {
201
+ vectors = [];
202
+ ready = false;
203
+ async add(embeddings, routes, utterances) {
204
+ if (embeddings.length !== routes.length || routes.length !== utterances.length) {
205
+ throw new Error("Embeddings, routes, and utterances must have same length");
206
+ }
207
+ for (let i = 0; i < embeddings.length; i++) {
208
+ this.vectors.push({
209
+ route: routes[i],
210
+ utterance: utterances[i],
211
+ embedding: embeddings[i]
212
+ });
213
+ }
214
+ this.ready = true;
215
+ }
216
+ async query(embedding, topK) {
217
+ if (this.vectors.length === 0) {
218
+ return [];
219
+ }
220
+ const scored = this.vectors.map((vector) => ({
221
+ route: vector.route,
222
+ utterance: vector.utterance,
223
+ score: this.cosineSimilarity(embedding, vector.embedding)
224
+ }));
225
+ scored.sort((a, b) => b.score - a.score);
226
+ return scored.slice(0, topK);
227
+ }
228
+ async clear() {
229
+ this.vectors = [];
230
+ this.ready = false;
231
+ }
232
+ isReady() {
233
+ return this.ready;
234
+ }
235
+ /**
236
+ * Get number of stored vectors
237
+ */
238
+ get size() {
239
+ return this.vectors.length;
240
+ }
241
+ /**
242
+ * Cosine similarity between two vectors
243
+ */
244
+ cosineSimilarity(a, b) {
245
+ if (a.length !== b.length) return 0;
246
+ let dotProduct = 0;
247
+ let normA = 0;
248
+ let normB = 0;
249
+ for (let i = 0; i < a.length; i++) {
250
+ dotProduct += a[i] * b[i];
251
+ normA += a[i] * a[i];
252
+ normB += b[i] * b[i];
253
+ }
254
+ if (normA === 0 || normB === 0) return 0;
255
+ return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
256
+ }
257
+ };
258
+
259
+ // src/router.ts
260
+ var SemanticRouter = class {
261
+ routes = [];
262
+ encoder;
263
+ index;
264
+ llm;
265
+ // Configuration
266
+ topK;
267
+ aggregation;
268
+ threshold;
269
+ // State
270
+ initialized = false;
271
+ initPromise = null;
272
+ constructor(config = {}) {
273
+ this.routes = config.routes || [];
274
+ this.encoder = config.encoder || new LocalEncoder();
275
+ this.index = new LocalIndex();
276
+ this.llm = config.llm;
277
+ this.topK = config.topK ?? 5;
278
+ this.aggregation = config.aggregation ?? "mean";
279
+ this.threshold = config.threshold ?? 0.4;
280
+ }
281
+ /**
282
+ * Initialize the router by encoding all routes.
283
+ * Safe to call multiple times.
284
+ */
285
+ async initialize() {
286
+ if (this.initialized) return;
287
+ if (this.initPromise) {
288
+ await this.initPromise;
289
+ return;
290
+ }
291
+ this.initPromise = this.doInitialize();
292
+ await this.initPromise;
293
+ }
294
+ async doInitialize() {
295
+ if (this.routes.length === 0) {
296
+ console.warn("[SemanticRouter] No routes configured");
297
+ this.initialized = true;
298
+ return;
299
+ }
300
+ console.log(`[SemanticRouter] Encoding ${this.routes.length} routes...`);
301
+ const start = Date.now();
302
+ const allUtterances = [];
303
+ const allRouteNames = [];
304
+ for (const route of this.routes) {
305
+ for (const utterance of route.utterances) {
306
+ allUtterances.push(utterance);
307
+ allRouteNames.push(route.name);
308
+ }
309
+ }
310
+ const embeddings = await this.encoder.encodeBatch(allUtterances);
311
+ await this.index.add(embeddings, allRouteNames, allUtterances);
312
+ this.initialized = true;
313
+ console.log(
314
+ `[SemanticRouter] Initialized in ${Date.now() - start}ms (${allUtterances.length} utterances from ${this.routes.length} routes)`
315
+ );
316
+ }
317
+ /**
318
+ * Route a query to the best matching route.
319
+ */
320
+ async route(query) {
321
+ if (!this.initialized) {
322
+ await this.initialize();
323
+ }
324
+ const queryEmbedding = await this.encoder.encode(query);
325
+ const matches = await this.index.query(queryEmbedding, this.topK);
326
+ if (matches.length === 0) {
327
+ return this.noMatch();
328
+ }
329
+ const routeScores = this.aggregateScores(matches);
330
+ routeScores.sort((a, b) => b.score - a.score);
331
+ const topMatch = routeScores[0];
332
+ if (topMatch.score < this.threshold) {
333
+ if (this.llm) {
334
+ return await this.llmFallback(query, routeScores);
335
+ }
336
+ return this.noMatch(routeScores);
337
+ }
338
+ const matchedRoute = this.routes.find((r) => r.name === topMatch.name);
339
+ return {
340
+ route: matchedRoute || null,
341
+ name: topMatch.name,
342
+ confidence: topMatch.score,
343
+ scores: routeScores
344
+ };
345
+ }
346
+ /**
347
+ * Shorthand to get just the route name
348
+ */
349
+ async classify(query) {
350
+ const result = await this.route(query);
351
+ return result.name;
352
+ }
353
+ /**
354
+ * Add a route dynamically
355
+ */
356
+ async addRoute(route) {
357
+ this.routes = this.routes.filter((r) => r.name !== route.name);
358
+ this.routes.push(route);
359
+ const embeddings = await this.encoder.encodeBatch(route.utterances);
360
+ const routeNames = route.utterances.map(() => route.name);
361
+ await this.index.add(embeddings, routeNames, route.utterances);
362
+ console.log(`[SemanticRouter] Added route: ${route.name} (${route.utterances.length} utterances)`);
363
+ }
364
+ /**
365
+ * Aggregate scores from multiple matches by route
366
+ */
367
+ aggregateScores(matches) {
368
+ const scoresByRoute = /* @__PURE__ */ new Map();
369
+ for (const match of matches) {
370
+ const scores = scoresByRoute.get(match.route) || [];
371
+ scores.push(match.score);
372
+ scoresByRoute.set(match.route, scores);
373
+ }
374
+ const aggregated = [];
375
+ for (const [name, scores] of scoresByRoute) {
376
+ let score;
377
+ switch (this.aggregation) {
378
+ case "max":
379
+ score = Math.max(...scores);
380
+ break;
381
+ case "sum":
382
+ score = scores.reduce((a, b) => a + b, 0);
383
+ break;
384
+ case "mean":
385
+ default:
386
+ score = scores.reduce((a, b) => a + b, 0) / scores.length;
387
+ break;
388
+ }
389
+ aggregated.push({ name, score });
390
+ }
391
+ return aggregated;
392
+ }
393
+ /**
394
+ * Use LLM to classify when similarity is low
395
+ */
396
+ async llmFallback(query, scores) {
397
+ if (!this.llm) {
398
+ return this.noMatch(scores);
399
+ }
400
+ console.log("[SemanticRouter] Using LLM fallback for low-confidence match");
401
+ const routeDescriptions = this.routes.map((r) => `- ${r.name}: ${r.description || r.utterances[0]}`).join("\n");
402
+ const prompt = `Classify the user's intent into exactly ONE of these routes:
403
+
404
+ ${routeDescriptions}
405
+
406
+ User query: "${query}"
407
+
408
+ Respond with ONLY the route name, nothing else.`;
409
+ try {
410
+ const response = await this.llm.generate(prompt);
411
+ const routeName = response.trim().toLowerCase();
412
+ const matchedRoute = this.routes.find(
413
+ (r) => r.name.toLowerCase() === routeName
414
+ );
415
+ if (matchedRoute) {
416
+ console.log(`[SemanticRouter] LLM classified as: ${matchedRoute.name}`);
417
+ return {
418
+ route: matchedRoute,
419
+ name: matchedRoute.name,
420
+ confidence: 0.7,
421
+ // LLM confidence estimate
422
+ scores
423
+ };
424
+ }
425
+ } catch (error) {
426
+ console.warn("[SemanticRouter] LLM fallback failed:", error);
427
+ }
428
+ return this.noMatch(scores);
429
+ }
430
+ /**
431
+ * Return a no-match result
432
+ */
433
+ noMatch(scores) {
434
+ return {
435
+ route: null,
436
+ name: null,
437
+ confidence: 0,
438
+ scores
439
+ };
440
+ }
441
+ /**
442
+ * Get router statistics
443
+ */
444
+ getStats() {
445
+ return {
446
+ routeCount: this.routes.length,
447
+ routes: this.routes.map((r) => r.name),
448
+ encoder: this.encoder.name,
449
+ threshold: this.threshold,
450
+ initialized: this.initialized
451
+ };
452
+ }
453
+ /**
454
+ * Check if router is ready
455
+ */
456
+ isReady() {
457
+ return this.initialized;
458
+ }
459
+ };
460
+ // Annotate the CommonJS export names for ESM import in node:
461
+ 0 && (module.exports = {
462
+ BaseEncoder,
463
+ LocalEncoder,
464
+ LocalIndex,
465
+ OpenAIEncoder,
466
+ SemanticRouter
467
+ });