@klhapp/skillmux 1.4.1 → 1.5.1
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/CHANGELOG.md +22 -0
- package/config.example.toml +6 -0
- package/config.remote.example.toml +7 -0
- package/docs/configuration.md +43 -19
- package/docs/releasing.md +1 -1
- package/docs/schema.json +60 -7
- package/package.json +1 -1
- package/src/adapters.ts +11 -4
- package/src/audit.ts +8 -1
- package/src/calibrate.ts +17 -2
- package/src/cli.ts +3 -1
- package/src/clients.ts +19 -0
- package/src/commands/config.ts +13 -0
- package/src/config-service.ts +43 -15
- package/src/config-watcher.ts +3 -0
- package/src/config.ts +183 -54
- package/src/db.ts +15 -3
- package/src/doctor.ts +17 -0
- package/src/eval.ts +69 -12
- package/src/metrics.ts +14 -0
- package/src/router-core.ts +131 -15
- package/src/server.ts +4 -0
- package/src/types.ts +29 -1
package/src/config.ts
CHANGED
|
@@ -26,16 +26,31 @@ const remoteThresholdsSchema = z.object({
|
|
|
26
26
|
}).strict();
|
|
27
27
|
|
|
28
28
|
const configSchema = z.object({
|
|
29
|
+
config: z.object({
|
|
30
|
+
environment_overrides: z.boolean().default(true),
|
|
31
|
+
}).strict().optional(),
|
|
29
32
|
vault_path: z.string().min(1),
|
|
30
33
|
local_vault_paths: z.array(z.string()),
|
|
31
34
|
state_dir: z.string().min(1),
|
|
32
|
-
recall: z.object({
|
|
35
|
+
recall: z.object({
|
|
36
|
+
k_lexical: z.number().int().positive(),
|
|
37
|
+
k_vector: z.number().int().positive(),
|
|
38
|
+
k_rerank: z.number().int().positive().optional(),
|
|
39
|
+
}).strict().transform((r) => ({
|
|
40
|
+
...r,
|
|
41
|
+
k_rerank: r.k_rerank ?? Math.min(10, r.k_lexical + r.k_vector),
|
|
42
|
+
})).refine((r) => r.k_rerank <= r.k_lexical + r.k_vector, {
|
|
43
|
+
message: "recall.k_rerank cannot exceed k_lexical + k_vector",
|
|
44
|
+
}),
|
|
45
|
+
output: z.object({
|
|
46
|
+
ambiguous_candidate_limit: z.number().int().positive().default(5),
|
|
47
|
+
}).strict().optional(),
|
|
33
48
|
thresholds: z.object({
|
|
34
|
-
candidate_limit: z.number().int().positive(),
|
|
49
|
+
candidate_limit: z.number().int().positive().optional(),
|
|
35
50
|
match_score: z.number().optional(),
|
|
36
51
|
match_margin: z.number().nonnegative().optional(),
|
|
37
52
|
candidate_floor: z.number().optional(),
|
|
38
|
-
}).strict(),
|
|
53
|
+
}).strict().optional(),
|
|
39
54
|
inference: z.discriminatedUnion("mode", [
|
|
40
55
|
z.object({
|
|
41
56
|
mode: z.literal("local"),
|
|
@@ -85,11 +100,15 @@ const configSchema = z.object({
|
|
|
85
100
|
export const LOCAL_BUNDLE_ID = "gte-small-v1";
|
|
86
101
|
|
|
87
102
|
const DEFAULTS: Config = {
|
|
103
|
+
config: {
|
|
104
|
+
environment_overrides: true,
|
|
105
|
+
},
|
|
88
106
|
vault_path: "~/skills",
|
|
89
107
|
local_vault_paths: [],
|
|
90
108
|
state_dir: "~/.local/state/skillmux",
|
|
91
|
-
recall: { k_lexical: 20, k_vector: 20 },
|
|
109
|
+
recall: { k_lexical: 20, k_vector: 20, k_rerank: 10 },
|
|
92
110
|
thresholds: { candidate_limit: 5 },
|
|
111
|
+
output: { ambiguous_candidate_limit: 5 },
|
|
93
112
|
inference: {
|
|
94
113
|
mode: "local",
|
|
95
114
|
bundle: LOCAL_BUNDLE_ID,
|
|
@@ -259,25 +278,90 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
259
278
|
if (!isPlainObject(parsed.inference.embedding)) {
|
|
260
279
|
throw new Error("Remote inference requires an inference.embedding section.");
|
|
261
280
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
281
|
+
}
|
|
282
|
+
if (parsed.thresholds && typeof parsed.thresholds === "object" && (parsed.thresholds as Record<string, unknown>).candidate_limit !== undefined) {
|
|
283
|
+
console.error("skillmux: thresholds.candidate_limit is deprecated, use output.ambiguous_candidate_limit instead");
|
|
284
|
+
if (!parsed.output || (parsed.output as Record<string, unknown>).ambiguous_candidate_limit === undefined) {
|
|
285
|
+
parsed.output = {
|
|
286
|
+
...(typeof parsed.output === "object" && parsed.output !== null ? parsed.output : {}),
|
|
287
|
+
ambiguous_candidate_limit: (parsed.thresholds as Record<string, unknown>).candidate_limit,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (parsed.inference && typeof parsed.inference === "object" && "mode" in parsed.inference) {
|
|
293
|
+
if (parsed.inference.mode === "remote") {
|
|
294
|
+
if ("thresholds" in parsed.inference) {
|
|
295
|
+
const rawRemoteThresholds = (parsed.inference as Record<string, unknown>).thresholds;
|
|
296
|
+
if (
|
|
297
|
+
typeof rawRemoteThresholds === "object" &&
|
|
298
|
+
rawRemoteThresholds !== null &&
|
|
299
|
+
!("match_score" in rawRemoteThresholds) &&
|
|
300
|
+
!("match_margin" in rawRemoteThresholds) &&
|
|
301
|
+
!("candidate_floor" in rawRemoteThresholds)
|
|
302
|
+
) {
|
|
303
|
+
throw new Error("Invalid inference.thresholds in config.toml: must specify at least one threshold.");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const withoutInference = { ...parsed };
|
|
307
|
+
delete withoutInference.inference;
|
|
308
|
+
merged = {
|
|
309
|
+
...deepMerge(baseConfig, withoutInference),
|
|
310
|
+
inference: configSchema.shape.inference.parse(parsed.inference),
|
|
311
|
+
};
|
|
312
|
+
} else {
|
|
313
|
+
merged = deepMerge(baseConfig, parsed);
|
|
314
|
+
}
|
|
268
315
|
} else {
|
|
269
316
|
merged = deepMerge(baseConfig, parsed);
|
|
270
317
|
}
|
|
271
318
|
}
|
|
272
319
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
merged.vault_path = process.env.VAULT_PATH;
|
|
320
|
+
if (!merged.output) {
|
|
321
|
+
merged.output = { ambiguous_candidate_limit: merged.thresholds?.candidate_limit ?? 5 };
|
|
276
322
|
}
|
|
277
|
-
if (
|
|
278
|
-
merged.
|
|
323
|
+
if (!merged.thresholds) {
|
|
324
|
+
merged.thresholds = { candidate_limit: merged.output.ambiguous_candidate_limit };
|
|
325
|
+
} else if (merged.thresholds.candidate_limit === undefined) {
|
|
326
|
+
merged.thresholds.candidate_limit = merged.output.ambiguous_candidate_limit;
|
|
279
327
|
}
|
|
328
|
+
|
|
329
|
+
// Warn about deprecated generic environment variables regardless of override policy
|
|
330
|
+
const GENERIC_ENV_MAPPINGS: Record<string, string> = {
|
|
331
|
+
VAULT_PATH: "SKILLMUX_VAULT_PATH",
|
|
332
|
+
STATE_DIR: "SKILLMUX_STATE_DIR",
|
|
333
|
+
RECALL_K_LEXICAL: "SKILLMUX_RECALL_K_LEXICAL",
|
|
334
|
+
RECALL_K_VECTOR: "SKILLMUX_RECALL_K_VECTOR",
|
|
335
|
+
RECALL_K_RERANK: "SKILLMUX_RECALL_K_RERANK",
|
|
336
|
+
AMBIGUOUS_CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
|
|
337
|
+
CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
|
|
338
|
+
EMBED_MODEL: "SKILLMUX_EMBED_MODEL",
|
|
339
|
+
EMBED_ENDPOINT: "SKILLMUX_EMBED_ENDPOINT",
|
|
340
|
+
EMBED_DIMENSION: "SKILLMUX_EMBED_DIMENSION",
|
|
341
|
+
EMBED_DEVICE: "SKILLMUX_EMBED_DEVICE",
|
|
342
|
+
EMBED_DTYPE: "SKILLMUX_EMBED_DTYPE",
|
|
343
|
+
RERANK_MODEL: "SKILLMUX_RERANK_MODEL",
|
|
344
|
+
RERANK_ENDPOINT: "SKILLMUX_RERANK_ENDPOINT",
|
|
345
|
+
RERANK_ADAPTER: "SKILLMUX_RERANK_ADAPTER",
|
|
346
|
+
HTTP_AUTH_ENABLED: "SKILLMUX_HTTP_AUTH_ENABLED",
|
|
347
|
+
HTTP_AUTH_TOKEN_ENV: "SKILLMUX_HTTP_AUTH_TOKEN_ENV",
|
|
348
|
+
HTTP_ALLOWED_ORIGINS: "SKILLMUX_HTTP_ALLOWED_ORIGINS",
|
|
349
|
+
HTTP_HOSTNAME: "SKILLMUX_HTTP_HOSTNAME",
|
|
350
|
+
HTTP_RATE_LIMIT_ENABLED: "SKILLMUX_HTTP_RATE_LIMIT_ENABLED",
|
|
351
|
+
HTTP_RATE_LIMIT_RPM: "SKILLMUX_HTTP_RATE_LIMIT_RPM",
|
|
352
|
+
HTTP_RATE_LIMIT_TRUST_PROXY: "SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY",
|
|
353
|
+
};
|
|
354
|
+
for (const [generic, preferred] of Object.entries(GENERIC_ENV_MAPPINGS)) {
|
|
355
|
+
if (process.env[generic] !== undefined && !warnedEnv.has(generic)) {
|
|
356
|
+
warnedEnv.add(generic);
|
|
357
|
+
console.error(`skillmux: ${generic} is deprecated, use ${preferred} instead`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const allowEnvOverrides = merged.config?.environment_overrides !== false;
|
|
362
|
+
|
|
280
363
|
const getEnv = (newPrefixed: string, unprefixed: string) => {
|
|
364
|
+
if (!allowEnvOverrides) return undefined;
|
|
281
365
|
const legacyPrefixed = newPrefixed.replace(/^SKILLMUX_/, "SKILL_ROUTER_");
|
|
282
366
|
if (process.env[newPrefixed] !== undefined) return process.env[newPrefixed];
|
|
283
367
|
if (process.env[legacyPrefixed] !== undefined) {
|
|
@@ -290,18 +374,57 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
290
374
|
return process.env[unprefixed];
|
|
291
375
|
};
|
|
292
376
|
|
|
377
|
+
// Environment variable overrides.
|
|
378
|
+
if (allowEnvOverrides) {
|
|
379
|
+
const vaultPath = getEnv("SKILLMUX_VAULT_PATH", "VAULT_PATH");
|
|
380
|
+
if (vaultPath) merged.vault_path = vaultPath;
|
|
381
|
+
const stateDir = getEnv("SKILLMUX_STATE_DIR", "STATE_DIR");
|
|
382
|
+
if (stateDir) merged.state_dir = stateDir;
|
|
383
|
+
const kLexicalStr = getEnv("SKILLMUX_RECALL_K_LEXICAL", "RECALL_K_LEXICAL");
|
|
384
|
+
if (kLexicalStr) {
|
|
385
|
+
const k = Number(kLexicalStr);
|
|
386
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_lexical: ${kLexicalStr}`);
|
|
387
|
+
merged.recall.k_lexical = k;
|
|
388
|
+
}
|
|
389
|
+
const kVectorStr = getEnv("SKILLMUX_RECALL_K_VECTOR", "RECALL_K_VECTOR");
|
|
390
|
+
if (kVectorStr) {
|
|
391
|
+
const k = Number(kVectorStr);
|
|
392
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_vector: ${kVectorStr}`);
|
|
393
|
+
merged.recall.k_vector = k;
|
|
394
|
+
}
|
|
395
|
+
const kRerankStr = getEnv("SKILLMUX_RECALL_K_RERANK", "RECALL_K_RERANK");
|
|
396
|
+
if (kRerankStr) {
|
|
397
|
+
const k = Number(kRerankStr);
|
|
398
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_rerank: ${kRerankStr}`);
|
|
399
|
+
merged.recall.k_rerank = k;
|
|
400
|
+
}
|
|
401
|
+
const ambiguousLimitStr =
|
|
402
|
+
getEnv("SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT", "AMBIGUOUS_CANDIDATE_LIMIT") ??
|
|
403
|
+
getEnv("SKILLMUX_CANDIDATE_LIMIT", "CANDIDATE_LIMIT");
|
|
404
|
+
if (ambiguousLimitStr) {
|
|
405
|
+
const lim = Number(ambiguousLimitStr);
|
|
406
|
+
if (!Number.isInteger(lim) || lim < 1) throw new Error(`Invalid output.ambiguous_candidate_limit: ${ambiguousLimitStr}`);
|
|
407
|
+
merged.output.ambiguous_candidate_limit = lim;
|
|
408
|
+
merged.thresholds.candidate_limit = lim;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
293
412
|
if (merged.inference.mode === "local") {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
warnedEnv.
|
|
298
|
-
|
|
413
|
+
if (allowEnvOverrides) {
|
|
414
|
+
let modelsDirEnv = process.env.SKILLMUX_MODELS_DIR;
|
|
415
|
+
if (modelsDirEnv === undefined && process.env.SKILL_ROUTER_MODELS_DIR !== undefined) {
|
|
416
|
+
if (!warnedEnv.has("SKILL_ROUTER_MODELS_DIR")) {
|
|
417
|
+
warnedEnv.add("SKILL_ROUTER_MODELS_DIR");
|
|
418
|
+
console.error("skillmux: SKILL_ROUTER_MODELS_DIR is deprecated, use SKILLMUX_MODELS_DIR instead");
|
|
419
|
+
}
|
|
420
|
+
modelsDirEnv = process.env.SKILL_ROUTER_MODELS_DIR;
|
|
299
421
|
}
|
|
300
|
-
modelsDirEnv =
|
|
422
|
+
if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
|
|
423
|
+
const embedDevice = getEnv("SKILLMUX_EMBED_DEVICE", "EMBED_DEVICE");
|
|
424
|
+
if (embedDevice) merged.inference.embedding.device = embedDevice as ONNXDevice;
|
|
425
|
+
const embedDtype = getEnv("SKILLMUX_EMBED_DTYPE", "EMBED_DTYPE");
|
|
426
|
+
if (embedDtype) merged.inference.embedding.dtype = embedDtype as ONNXDtype;
|
|
301
427
|
}
|
|
302
|
-
if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
|
|
303
|
-
if (process.env.EMBED_DEVICE) merged.inference.embedding.device = process.env.EMBED_DEVICE as ONNXDevice;
|
|
304
|
-
if (process.env.EMBED_DTYPE) merged.inference.embedding.dtype = process.env.EMBED_DTYPE as ONNXDtype;
|
|
305
428
|
} else if (merged.inference.mode === "remote") {
|
|
306
429
|
if (!merged.inference.embedding) {
|
|
307
430
|
throw new Error("Remote inference requires an inference.embedding section.");
|
|
@@ -385,43 +508,49 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
385
508
|
|
|
386
509
|
// HTTP server environment overrides
|
|
387
510
|
if (merged.server) {
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
511
|
+
if (allowEnvOverrides) {
|
|
512
|
+
const authEnabledStr = getEnv("SKILLMUX_HTTP_AUTH_ENABLED", "HTTP_AUTH_ENABLED");
|
|
513
|
+
if (authEnabledStr !== undefined) {
|
|
514
|
+
merged.server.auth_enabled = authEnabledStr === "true";
|
|
515
|
+
}
|
|
516
|
+
const authTokenEnv = getEnv("SKILLMUX_HTTP_AUTH_TOKEN_ENV", "HTTP_AUTH_TOKEN_ENV");
|
|
517
|
+
if (authTokenEnv !== undefined) {
|
|
518
|
+
merged.server.auth_token_env = authTokenEnv;
|
|
519
|
+
}
|
|
520
|
+
const allowedOriginsStr = getEnv("SKILLMUX_HTTP_ALLOWED_ORIGINS", "HTTP_ALLOWED_ORIGINS");
|
|
521
|
+
if (allowedOriginsStr !== undefined) {
|
|
522
|
+
merged.server.allowed_origins = allowedOriginsStr.split(",").map((o) => o.trim());
|
|
523
|
+
}
|
|
524
|
+
const hostname = getEnv("SKILLMUX_HTTP_HOSTNAME", "HTTP_HOSTNAME");
|
|
525
|
+
if (hostname !== undefined) {
|
|
526
|
+
merged.server.hostname = hostname;
|
|
527
|
+
}
|
|
400
528
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
529
|
+
if (!merged.server.rate_limit) {
|
|
530
|
+
merged.server.rate_limit = { enabled: false, requests_per_minute: 60 };
|
|
531
|
+
}
|
|
404
532
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
533
|
+
const rateLimitEnabledStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_ENABLED", "HTTP_RATE_LIMIT_ENABLED");
|
|
534
|
+
if (rateLimitEnabledStr) {
|
|
535
|
+
merged.server.rate_limit.enabled = rateLimitEnabledStr === "true";
|
|
536
|
+
}
|
|
409
537
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
538
|
+
const rateLimitRPMStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_RPM", "HTTP_RATE_LIMIT_RPM");
|
|
539
|
+
if (rateLimitRPMStr) {
|
|
540
|
+
const rpm = Number(rateLimitRPMStr);
|
|
541
|
+
if (!Number.isInteger(rpm)) {
|
|
542
|
+
throw new Error(`Invalid rate limit RPM: ${rateLimitRPMStr}`);
|
|
543
|
+
}
|
|
544
|
+
merged.server.rate_limit.requests_per_minute = rpm;
|
|
415
545
|
}
|
|
416
|
-
merged.server.rate_limit.requests_per_minute = rpm;
|
|
417
|
-
}
|
|
418
546
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
547
|
+
const rateLimitTrustProxyStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY", "HTTP_RATE_LIMIT_TRUST_PROXY");
|
|
548
|
+
if (rateLimitTrustProxyStr) {
|
|
549
|
+
merged.server.rate_limit.trust_proxy = rateLimitTrustProxyStr === "true";
|
|
550
|
+
}
|
|
422
551
|
}
|
|
423
552
|
|
|
424
|
-
if (merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
|
|
553
|
+
if (merged.server.rate_limit && merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
|
|
425
554
|
merged.server.rate_limit.requests_per_minute = 60;
|
|
426
555
|
}
|
|
427
556
|
}
|
package/src/db.ts
CHANGED
|
@@ -46,6 +46,8 @@ export function openIndex(stateDir: string): Database {
|
|
|
46
46
|
outcome TEXT NOT NULL CHECK (outcome IN ('matched', 'ambiguous', 'no_match')),
|
|
47
47
|
degraded INTEGER NOT NULL,
|
|
48
48
|
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
49
|
+
degraded_from TEXT,
|
|
50
|
+
degradation_reason TEXT,
|
|
49
51
|
candidates TEXT NOT NULL,
|
|
50
52
|
selected_skill_id TEXT,
|
|
51
53
|
latency_ms INTEGER NOT NULL
|
|
@@ -54,6 +56,12 @@ export function openIndex(stateDir: string): Database {
|
|
|
54
56
|
if (!auditColumns.some((column) => column.name === "retrieval")) {
|
|
55
57
|
db.run("ALTER TABLE audit ADD COLUMN retrieval TEXT NOT NULL DEFAULT 'lexical'");
|
|
56
58
|
}
|
|
59
|
+
if (!auditColumns.some((column) => column.name === "degraded_from")) {
|
|
60
|
+
db.run("ALTER TABLE audit ADD COLUMN degraded_from TEXT");
|
|
61
|
+
}
|
|
62
|
+
if (!auditColumns.some((column) => column.name === "degradation_reason")) {
|
|
63
|
+
db.run("ALTER TABLE audit ADD COLUMN degradation_reason TEXT");
|
|
64
|
+
}
|
|
57
65
|
db.run(`CREATE TABLE IF NOT EXISTS index_meta (
|
|
58
66
|
key TEXT PRIMARY KEY,
|
|
59
67
|
value TEXT NOT NULL
|
|
@@ -257,6 +265,8 @@ export interface AuditInsert {
|
|
|
257
265
|
query: string;
|
|
258
266
|
outcome: string;
|
|
259
267
|
retrieval: AuditRow["retrieval"];
|
|
268
|
+
degraded_from?: string | null;
|
|
269
|
+
degradation_reason?: string | null;
|
|
260
270
|
candidates: AuditCandidate[];
|
|
261
271
|
selected_skill_id: string | null;
|
|
262
272
|
latency_ms: number;
|
|
@@ -264,14 +274,16 @@ export interface AuditInsert {
|
|
|
264
274
|
|
|
265
275
|
export function insertAudit(db: Database, row: AuditInsert): void {
|
|
266
276
|
db.run(
|
|
267
|
-
`INSERT INTO audit (ts, query, outcome, degraded, retrieval, candidates, selected_skill_id, latency_ms)
|
|
268
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
277
|
+
`INSERT INTO audit (ts, query, outcome, degraded, retrieval, degraded_from, degradation_reason, candidates, selected_skill_id, latency_ms)
|
|
278
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
269
279
|
[
|
|
270
280
|
row.ts,
|
|
271
281
|
row.query,
|
|
272
282
|
row.outcome,
|
|
273
|
-
row.retrieval === "lexical" ? 1 : 0,
|
|
283
|
+
row.retrieval === "lexical" || row.degradation_reason ? 1 : 0,
|
|
274
284
|
row.retrieval,
|
|
285
|
+
row.degraded_from ?? null,
|
|
286
|
+
row.degradation_reason ?? null,
|
|
275
287
|
JSON.stringify(row.candidates),
|
|
276
288
|
row.selected_skill_id,
|
|
277
289
|
row.latency_ms,
|
package/src/doctor.ts
CHANGED
|
@@ -90,6 +90,11 @@ function checkCalibration(config: Config): DoctorCheck {
|
|
|
90
90
|
rerankerFingerprint(config) !== run.reranker_fingerprint ? "reranker" : null,
|
|
91
91
|
embeddingFingerprint(config) !== run.embedding_fingerprint ? "embedding" : null,
|
|
92
92
|
currentCorpusFingerprint !== run.corpus_fingerprint ? "vault contents" : null,
|
|
93
|
+
run.recall_settings && (
|
|
94
|
+
run.recall_settings.k_lexical !== config.recall.k_lexical ||
|
|
95
|
+
run.recall_settings.k_vector !== config.recall.k_vector ||
|
|
96
|
+
run.recall_settings.k_rerank !== (config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector))
|
|
97
|
+
) ? "recall settings" : null,
|
|
93
98
|
].filter((part): part is string => part !== null);
|
|
94
99
|
|
|
95
100
|
if (stale.length > 0) {
|
|
@@ -109,8 +114,20 @@ export { describeDeployment };
|
|
|
109
114
|
export async function diagnose(
|
|
110
115
|
config: Config,
|
|
111
116
|
environment: Record<string, string | undefined> = process.env,
|
|
117
|
+
sources: Record<string, "default" | "toml" | "environment" | "admin"> = {},
|
|
112
118
|
): Promise<DoctorReport> {
|
|
113
119
|
const checks: DoctorCheck[] = [];
|
|
120
|
+
const envOverrides = config.config?.environment_overrides !== false;
|
|
121
|
+
checks.push({
|
|
122
|
+
name: "config_authority",
|
|
123
|
+
ok: true,
|
|
124
|
+
detail: envOverrides
|
|
125
|
+
? "environment overrides enabled"
|
|
126
|
+
: "TOML authoritative (environment overrides disabled)",
|
|
127
|
+
});
|
|
128
|
+
for (const [key, source] of Object.entries(sources)) {
|
|
129
|
+
checks.push({ name: `config_source:${key}`, ok: true, detail: source });
|
|
130
|
+
}
|
|
114
131
|
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
115
132
|
|
|
116
133
|
for (const localPath of config.local_vault_paths) {
|
package/src/eval.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import {
|
|
5
|
+
backfillEmbeddings,
|
|
6
|
+
decideRetrievalResult,
|
|
7
|
+
getRuntime,
|
|
8
|
+
retrieveAndRerank,
|
|
9
|
+
} from "./router-core";
|
|
8
10
|
|
|
9
11
|
export interface EvalCase {
|
|
10
12
|
query: string;
|
|
@@ -26,10 +28,34 @@ export interface EvalMetrics {
|
|
|
26
28
|
mrr: number;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
export interface CandidateEvalDetail {
|
|
32
|
+
skill_id: string;
|
|
33
|
+
lexical_rank: number | null;
|
|
34
|
+
fused_rank: number | null;
|
|
35
|
+
reranked_rank?: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EvalCaseResult {
|
|
39
|
+
query: string;
|
|
40
|
+
expected: string[];
|
|
41
|
+
outcome: "matched" | "ambiguous" | "no_match";
|
|
42
|
+
retrieval: string;
|
|
43
|
+
degraded_from?: string | null;
|
|
44
|
+
degradation_reason?: string | null;
|
|
45
|
+
latency_ms: number;
|
|
46
|
+
recall_settings: {
|
|
47
|
+
k_lexical: number;
|
|
48
|
+
k_vector: number;
|
|
49
|
+
k_rerank: number;
|
|
50
|
+
};
|
|
51
|
+
candidates: CandidateEvalDetail[];
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
export interface EvalReport {
|
|
30
55
|
queries: number;
|
|
31
56
|
lexical: EvalMetrics;
|
|
32
57
|
hybrid: EvalMetrics;
|
|
58
|
+
cases?: EvalCaseResult[];
|
|
33
59
|
}
|
|
34
60
|
|
|
35
61
|
function metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
|
|
@@ -70,24 +96,55 @@ export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "querie
|
|
|
70
96
|
|
|
71
97
|
|
|
72
98
|
export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
73
|
-
const { config
|
|
74
|
-
if (config.inference.mode !== "local") throw new Error('Default evaluation requires inference.mode = "local".');
|
|
99
|
+
const { config } = await getRuntime();
|
|
75
100
|
await backfillEmbeddings();
|
|
76
101
|
|
|
77
102
|
const lexicalRankings: string[][] = [];
|
|
78
103
|
const hybridRankings: string[][] = [];
|
|
104
|
+
const caseResults: EvalCaseResult[] = [];
|
|
105
|
+
const kRerank = config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector);
|
|
106
|
+
|
|
79
107
|
for (const evalCase of cases) {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
const start = performance.now();
|
|
109
|
+
const retrievalResult = await retrieveAndRerank({ query: evalCase.query });
|
|
110
|
+
const decision = decideRetrievalResult(config, retrievalResult);
|
|
111
|
+
const fusedRanking = retrievalResult.trace
|
|
112
|
+
.filter((candidate) => candidate.fused_rank !== null)
|
|
113
|
+
.sort((a, b) => a.fused_rank! - b.fused_rank!)
|
|
114
|
+
.map((candidate) => candidate.skill_id);
|
|
115
|
+
|
|
116
|
+
lexicalRankings.push(retrievalResult.trace
|
|
117
|
+
.filter((candidate) => candidate.lexical_rank !== null)
|
|
118
|
+
.sort((a, b) => a.lexical_rank! - b.lexical_rank!)
|
|
119
|
+
.map((candidate) => candidate.skill_id));
|
|
120
|
+
hybridRankings.push(fusedRanking.length > 0
|
|
121
|
+
? fusedRanking
|
|
122
|
+
: retrievalResult.candidates.map((candidate) => candidate.skill_id));
|
|
123
|
+
|
|
124
|
+
const latency_ms = Math.round(performance.now() - start);
|
|
125
|
+
const candidateDetails: CandidateEvalDetail[] = retrievalResult.trace;
|
|
126
|
+
|
|
127
|
+
caseResults.push({
|
|
128
|
+
query: evalCase.query,
|
|
129
|
+
expected: evalCase.expected,
|
|
130
|
+
outcome: decision.outcome,
|
|
131
|
+
retrieval: retrievalResult.retrieval,
|
|
132
|
+
degraded_from: retrievalResult.degraded_from ?? null,
|
|
133
|
+
degradation_reason: retrievalResult.degradation_reason ?? null,
|
|
134
|
+
latency_ms,
|
|
135
|
+
recall_settings: {
|
|
136
|
+
k_lexical: config.recall.k_lexical,
|
|
137
|
+
k_vector: config.recall.k_vector,
|
|
138
|
+
k_rerank: kRerank,
|
|
139
|
+
},
|
|
140
|
+
candidates: candidateDetails,
|
|
141
|
+
});
|
|
86
142
|
}
|
|
87
143
|
|
|
88
144
|
return {
|
|
89
145
|
queries: cases.length,
|
|
90
146
|
lexical: metrics(lexicalRankings, cases),
|
|
91
147
|
hybrid: metrics(hybridRankings, cases),
|
|
148
|
+
cases: caseResults,
|
|
92
149
|
};
|
|
93
150
|
}
|
package/src/metrics.ts
CHANGED
|
@@ -18,6 +18,7 @@ export class MetricsRegistry {
|
|
|
18
18
|
|
|
19
19
|
private errors = 0;
|
|
20
20
|
private rateLimitsExceeded = 0;
|
|
21
|
+
private degradations = new Map<string, number>();
|
|
21
22
|
private readiness: ReadinessSnapshot | null = null;
|
|
22
23
|
private deployment: MetricsDeploymentIdentity | null = null;
|
|
23
24
|
|
|
@@ -37,6 +38,11 @@ export class MetricsRegistry {
|
|
|
37
38
|
this.outcomes.set(outcome, (this.outcomes.get(outcome) || 0) + 1);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
recordDegradation(stage: "embedding" | "reranker", reason: string) {
|
|
42
|
+
const key = `${stage}:${reason}`;
|
|
43
|
+
this.degradations.set(key, (this.degradations.get(key) || 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
recordResolveLatencySeconds(seconds: number) {
|
|
41
47
|
this.latencySum += seconds;
|
|
42
48
|
this.latencyCount++;
|
|
@@ -98,6 +104,14 @@ export class MetricsRegistry {
|
|
|
98
104
|
lines.push("# TYPE skill_router_rate_limits_exceeded_total counter");
|
|
99
105
|
lines.push(`skill_router_rate_limits_exceeded_total ${this.rateLimitsExceeded}`);
|
|
100
106
|
|
|
107
|
+
// Degraded retrieval total
|
|
108
|
+
lines.push("# HELP skill_router_degraded_retrieval_total Total count of degraded retrieval fallbacks labelled by stage and reason.");
|
|
109
|
+
lines.push("# TYPE skill_router_degraded_retrieval_total counter");
|
|
110
|
+
for (const [key, count] of this.degradations) {
|
|
111
|
+
const [stage, reason] = key.split(":");
|
|
112
|
+
lines.push(`skill_router_degraded_retrieval_total{stage="${stage}",reason="${reason}"} ${count}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
lines.push("# HELP skill_router_ready Whether the service is ready to route requests.");
|
|
102
116
|
lines.push("# TYPE skill_router_ready gauge");
|
|
103
117
|
lines.push(`skill_router_ready ${this.readiness?.status === "ready" ? 1 : 0}`);
|