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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/index.d.mts +272 -0
- package/dist/index.d.ts +272 -0
- package/dist/index.js +467 -0
- package/dist/index.mjs +426 -0
- package/package.json +70 -0
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
|
+
});
|