@klhapp/skillmux 1.5.2 → 1.7.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/CHANGELOG.md +21 -0
- package/README.md +7 -7
- package/config.example.toml +5 -0
- package/config.remote.example.toml +4 -7
- package/docs/README.md +4 -4
- package/docs/assets/architecture.svg +1 -1
- package/docs/cli.md +4 -45
- package/docs/concepts.md +12 -14
- package/docs/configuration.md +10 -11
- package/docs/deployment.md +11 -8
- package/docs/getting-started.md +1 -1
- package/docs/mcp-routing.md +28 -23
- package/docs/ranked-shortlist-migration.md +160 -0
- package/docs/schema.json +37 -139
- package/docs/skill-management.md +7 -2
- package/docs/troubleshooting.md +12 -14
- package/package.json +1 -1
- package/src/adapters.ts +1 -422
- package/src/audit.ts +1 -3
- package/src/cli.ts +19 -229
- package/src/completions.ts +0 -4
- package/src/config-service.ts +19 -32
- package/src/config-watcher.ts +2 -6
- package/src/config.ts +61 -60
- package/src/db.ts +32 -18
- package/src/doctor.ts +1 -84
- package/src/eval.ts +143 -60
- package/src/init.ts +4 -5
- package/src/metrics.ts +1 -13
- package/src/router-core.ts +42 -149
- package/src/server.ts +13 -27
- package/src/stats.ts +126 -57
- package/src/types.ts +8 -39
- package/docs/calibration.md +0 -162
- package/src/calibrate.ts +0 -1594
- package/src/config-mutation.ts +0 -65
- package/src/dataset-generator.ts +0 -119
- package/src/decision.ts +0 -45
package/src/router-core.ts
CHANGED
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
vectorTopK,
|
|
24
24
|
} from "./db";
|
|
25
25
|
import type { SkillRow } from "./db";
|
|
26
|
-
import { decideResolveOutcome, type Decision } from "./decision";
|
|
27
26
|
import type {
|
|
28
27
|
RankedCandidate,
|
|
29
28
|
RetrievalCapability,
|
|
@@ -54,7 +53,6 @@ function maxVaultMtime(vaultPath: string, localVaultPaths: string[]): number {
|
|
|
54
53
|
|
|
55
54
|
export { buildAuditRow } from "./audit";
|
|
56
55
|
export { loadConfig } from "./config";
|
|
57
|
-
export { decideResolveOutcome } from "./decision";
|
|
58
56
|
export type * from "./types";
|
|
59
57
|
|
|
60
58
|
const NO_MATCH_MESSAGE =
|
|
@@ -370,87 +368,40 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
370
368
|
const { config, db } = await getEnv();
|
|
371
369
|
await syncVaultIfNeeded();
|
|
372
370
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
content_sha256: delivery.content_sha256,
|
|
383
|
-
score: 1.0,
|
|
384
|
-
margin: 1.0,
|
|
385
|
-
body: delivery.body,
|
|
386
|
-
files: delivery.files,
|
|
387
|
-
};
|
|
388
|
-
insertAudit(
|
|
389
|
-
db,
|
|
390
|
-
buildAuditRow({
|
|
391
|
-
id: 0,
|
|
392
|
-
ts: new Date().toISOString(),
|
|
393
|
-
query: input.query,
|
|
394
|
-
outcome: "matched",
|
|
395
|
-
retrieval: "exact",
|
|
396
|
-
candidates: [{ skill_id: exactMatch.skill_id, score: 1.0 }],
|
|
397
|
-
selected_skill_id: exactMatch.skill_id,
|
|
398
|
-
latency_ms: Math.round(performance.now() - t0),
|
|
399
|
-
}),
|
|
400
|
-
);
|
|
401
|
-
return result;
|
|
371
|
+
if (input.top_k !== undefined) {
|
|
372
|
+
if (!Number.isInteger(input.top_k) || input.top_k < 1) {
|
|
373
|
+
throw new Error(`Invalid top_k: ${input.top_k} must be a positive integer`);
|
|
374
|
+
}
|
|
375
|
+
if (input.top_k > config.output.max_top_k) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Invalid top_k: ${input.top_k} exceeds max_top_k of ${config.output.max_top_k}`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
402
380
|
}
|
|
403
381
|
|
|
382
|
+
const effectiveTopK = input.top_k ?? config.output.top_k;
|
|
404
383
|
const retrievalResult = await retrieveAndRerank(input);
|
|
405
384
|
const { retrieval, candidates: rankedCandidates } = retrievalResult;
|
|
406
385
|
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
body: delivery.body,
|
|
427
|
-
files: delivery.files,
|
|
428
|
-
};
|
|
429
|
-
} else if (decision.outcome === "ambiguous") {
|
|
430
|
-
result = {
|
|
431
|
-
outcome: "ambiguous",
|
|
432
|
-
retrieval,
|
|
433
|
-
...(retrievalResult.degraded_from
|
|
434
|
-
? {
|
|
435
|
-
degraded_from: retrievalResult.degraded_from,
|
|
436
|
-
degradation_reason: retrievalResult.degradation_reason,
|
|
437
|
-
}
|
|
438
|
-
: {}),
|
|
439
|
-
candidates: decision.candidates.map(({ score: _score, ...candidate }) => candidate),
|
|
440
|
-
};
|
|
441
|
-
} else {
|
|
442
|
-
result = {
|
|
443
|
-
outcome: "no_match",
|
|
444
|
-
retrieval,
|
|
445
|
-
...(retrievalResult.degraded_from
|
|
446
|
-
? {
|
|
447
|
-
degraded_from: retrievalResult.degraded_from,
|
|
448
|
-
degradation_reason: retrievalResult.degradation_reason,
|
|
449
|
-
}
|
|
450
|
-
: {}),
|
|
451
|
-
message: NO_MATCH_MESSAGE,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
386
|
+
const candidates: RankedCandidate[] = rankedCandidates
|
|
387
|
+
.slice(0, effectiveTopK)
|
|
388
|
+
.map((c, index) => ({
|
|
389
|
+
rank: index + 1,
|
|
390
|
+
skill_id: c.skill_id,
|
|
391
|
+
description: c.description,
|
|
392
|
+
score: c.score,
|
|
393
|
+
}));
|
|
394
|
+
|
|
395
|
+
const result: ResolveResult = {
|
|
396
|
+
retrieval,
|
|
397
|
+
...(retrievalResult.degraded_from
|
|
398
|
+
? {
|
|
399
|
+
degraded_from: retrievalResult.degraded_from,
|
|
400
|
+
degradation_reason: retrievalResult.degradation_reason,
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
403
|
+
candidates,
|
|
404
|
+
};
|
|
454
405
|
|
|
455
406
|
insertAudit(
|
|
456
407
|
db,
|
|
@@ -458,12 +409,10 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
458
409
|
id: 0, // assigned by SQLite
|
|
459
410
|
ts: new Date().toISOString(),
|
|
460
411
|
query: input.query,
|
|
461
|
-
outcome: result.outcome,
|
|
462
412
|
retrieval,
|
|
463
413
|
degraded_from: retrievalResult.degraded_from ?? null,
|
|
464
414
|
degradation_reason: retrievalResult.degradation_reason ?? null,
|
|
465
|
-
candidates:
|
|
466
|
-
selected_skill_id: result.outcome === "matched" ? result.skill_id : null,
|
|
415
|
+
candidates: candidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
|
|
467
416
|
latency_ms: Math.round(performance.now() - t0),
|
|
468
417
|
}),
|
|
469
418
|
);
|
|
@@ -471,11 +420,18 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
471
420
|
return result;
|
|
472
421
|
}
|
|
473
422
|
|
|
423
|
+
export interface RawCandidate {
|
|
424
|
+
skill_id: string;
|
|
425
|
+
title: string;
|
|
426
|
+
description: string;
|
|
427
|
+
score: number | null;
|
|
428
|
+
}
|
|
429
|
+
|
|
474
430
|
export interface RetrievalResult {
|
|
475
431
|
retrieval: Exclude<RetrievalCapability, "exact">;
|
|
476
432
|
degraded_from?: "reranked" | "hybrid";
|
|
477
433
|
degradation_reason?: DegradationReason;
|
|
478
|
-
candidates:
|
|
434
|
+
candidates: RawCandidate[];
|
|
479
435
|
trace: Array<{
|
|
480
436
|
skill_id: string;
|
|
481
437
|
lexical_rank: number | null;
|
|
@@ -484,18 +440,6 @@ export interface RetrievalResult {
|
|
|
484
440
|
}>;
|
|
485
441
|
}
|
|
486
442
|
|
|
487
|
-
export function decideRetrievalResult(config: Config, result: RetrievalResult): Decision {
|
|
488
|
-
const candidateLimit = config.output?.ambiguous_candidate_limit ?? config.thresholds?.candidate_limit ?? 5;
|
|
489
|
-
const thresholds = result.retrieval === "reranked" && config.inference.mode === "remote"
|
|
490
|
-
? { candidate_limit: candidateLimit, ...config.inference.thresholds }
|
|
491
|
-
: { ...config.thresholds, candidate_limit: candidateLimit };
|
|
492
|
-
return decideResolveOutcome({
|
|
493
|
-
reranked: result.retrieval === "reranked",
|
|
494
|
-
candidates: result.candidates,
|
|
495
|
-
thresholds,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
|
|
499
443
|
export function classifyInferenceError(
|
|
500
444
|
stage: "embedding" | "reranker",
|
|
501
445
|
error: unknown,
|
|
@@ -530,64 +474,26 @@ export function classifyInferenceError(
|
|
|
530
474
|
return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
|
|
531
475
|
}
|
|
532
476
|
|
|
533
|
-
export interface StageTiming {
|
|
534
|
-
embedding_ms: number;
|
|
535
|
-
lexical_ms: number;
|
|
536
|
-
vector_ms: number;
|
|
537
|
-
reranker_ms: number;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
export interface RetrieveSnapshotOptions {
|
|
541
|
-
onTiming?: (timing: StageTiming) => void;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
477
|
/**
|
|
545
|
-
* Retrieve the full fused candidate set
|
|
546
|
-
*
|
|
478
|
+
* Retrieve the full fused candidate set, optionally rerank it, and synchronize
|
|
479
|
+
* the vault before reading the index.
|
|
547
480
|
*/
|
|
548
481
|
export async function retrieveAndRerank(
|
|
549
482
|
input: ResolveSkillInput,
|
|
550
483
|
): Promise<RetrievalResult> {
|
|
551
484
|
await syncVaultIfNeeded();
|
|
552
|
-
return retrieveAndRerankSnapshot(input);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Retrieve candidates against an already-synchronized index snapshot without
|
|
557
|
-
* triggering syncVaultIfNeeded(). Calibration uses this to avoid observing the
|
|
558
|
-
* policy it is trying to replace while keeping one corpus for the whole run.
|
|
559
|
-
*/
|
|
560
|
-
export async function retrieveAndRerankSnapshot(
|
|
561
|
-
input: ResolveSkillInput,
|
|
562
|
-
options?: RetrieveSnapshotOptions,
|
|
563
|
-
): Promise<RetrievalResult> {
|
|
564
485
|
const { config, db } = await getEnv();
|
|
565
486
|
const clients = getClients();
|
|
566
|
-
const measureTiming = options?.onTiming !== undefined;
|
|
567
487
|
|
|
568
|
-
let embedding_ms = 0;
|
|
569
|
-
let lexical_ms = 0;
|
|
570
|
-
let vector_ms = 0;
|
|
571
|
-
let reranker_ms = 0;
|
|
572
|
-
|
|
573
|
-
const tEmbed0 = measureTiming ? performance.now() : 0;
|
|
574
488
|
const embedPromise =
|
|
575
489
|
!input.forceLexical && clients.embed
|
|
576
490
|
? clients.embed([input.query]).then(
|
|
577
|
-
(res) =>
|
|
578
|
-
|
|
579
|
-
return res;
|
|
580
|
-
},
|
|
581
|
-
(err) => {
|
|
582
|
-
if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
|
|
583
|
-
return { error: err };
|
|
584
|
-
},
|
|
491
|
+
(res) => res,
|
|
492
|
+
(err) => ({ error: err }),
|
|
585
493
|
)
|
|
586
494
|
: null;
|
|
587
495
|
|
|
588
|
-
const tLex0 = measureTiming ? performance.now() : 0;
|
|
589
496
|
const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
|
|
590
|
-
if (measureTiming) lexical_ms = Math.max(0, performance.now() - tLex0);
|
|
591
497
|
const lexicalRanks = new Map(lexical.map((row, index) => [row.skill_id, index + 1]));
|
|
592
498
|
|
|
593
499
|
let retrieval: RetrievalResult["retrieval"] = "lexical";
|
|
@@ -611,17 +517,14 @@ export async function retrieveAndRerankSnapshot(
|
|
|
611
517
|
}),
|
|
612
518
|
);
|
|
613
519
|
} else {
|
|
614
|
-
const tVec0 = measureTiming ? performance.now() : 0;
|
|
615
520
|
try {
|
|
616
521
|
const queryVec = (embedRes as Float32Array[])[0];
|
|
617
522
|
if (!queryVec) throw new Error("Embedding client returned no query vector.");
|
|
618
523
|
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
619
|
-
if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
|
|
620
524
|
rows = reciprocalRankFusion(lexical, nearest);
|
|
621
525
|
fusedRows = rows;
|
|
622
526
|
retrieval = "hybrid";
|
|
623
527
|
} catch (embedError) {
|
|
624
|
-
if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
|
|
625
528
|
retrieval = "lexical";
|
|
626
529
|
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
627
530
|
degradation_reason = classifyInferenceError("embedding", embedError);
|
|
@@ -641,17 +544,14 @@ export async function retrieveAndRerankSnapshot(
|
|
|
641
544
|
if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
|
|
642
545
|
const kRerank = config.recall.k_rerank ?? 10;
|
|
643
546
|
const rerankCandidates = rows.slice(0, kRerank);
|
|
644
|
-
const tRerank0 = measureTiming ? performance.now() : 0;
|
|
645
547
|
try {
|
|
646
548
|
scores = await clients.rerank(
|
|
647
549
|
input.query,
|
|
648
550
|
rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
|
|
649
551
|
);
|
|
650
|
-
if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
|
|
651
552
|
retrieval = "reranked";
|
|
652
553
|
rows = rerankCandidates;
|
|
653
554
|
} catch (rerankError) {
|
|
654
|
-
if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
|
|
655
555
|
scores = null;
|
|
656
556
|
degraded_from = "reranked";
|
|
657
557
|
degradation_reason = classifyInferenceError("reranker", rerankError);
|
|
@@ -679,13 +579,6 @@ export async function retrieveAndRerankSnapshot(
|
|
|
679
579
|
: new Map<string, number>();
|
|
680
580
|
const traceRows = fusedRows ?? rows;
|
|
681
581
|
|
|
682
|
-
options?.onTiming?.({
|
|
683
|
-
embedding_ms,
|
|
684
|
-
lexical_ms,
|
|
685
|
-
vector_ms,
|
|
686
|
-
reranker_ms,
|
|
687
|
-
});
|
|
688
|
-
|
|
689
582
|
return {
|
|
690
583
|
retrieval,
|
|
691
584
|
...(degraded_from ? { degraded_from, degradation_reason } : {}),
|
package/src/server.ts
CHANGED
|
@@ -74,30 +74,29 @@ export function createMcpServer(): McpServer {
|
|
|
74
74
|
"resolve_skill",
|
|
75
75
|
{
|
|
76
76
|
description:
|
|
77
|
-
"Route a natural-language task description to
|
|
78
|
-
"Returns
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
"Route a natural-language task description to candidate skills in the vault. " +
|
|
78
|
+
"Returns a ranked shortlist of candidates. Use fetch_skill to retrieve a candidate's complete instructions.",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
query: z.string().min(1).describe("Natural-language task or prompt description to route."),
|
|
81
|
+
top_k: z
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.min(1)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("Optional maximum number of ranked candidates to return (subject to server max_top_k limit)."),
|
|
87
|
+
},
|
|
81
88
|
},
|
|
82
|
-
async ({ query }) => {
|
|
89
|
+
async ({ query, top_k }) => {
|
|
83
90
|
const startTime = performance.now();
|
|
84
91
|
try {
|
|
85
|
-
const result = await resolveSkill({ query });
|
|
92
|
+
const result = await resolveSkill({ query, top_k });
|
|
86
93
|
const duration = (performance.now() - startTime) / 1000;
|
|
87
94
|
metricsRegistry.recordResolveLatencySeconds(duration);
|
|
88
|
-
metricsRegistry.recordResolveOutcome(result.outcome);
|
|
89
95
|
if (result.degradation_reason) {
|
|
90
96
|
const stage = result.degradation_reason.startsWith("embedding_") ? "embedding" : "reranker";
|
|
91
97
|
metricsRegistry.recordDegradation(stage, result.degradation_reason);
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
if (result.outcome === "matched") {
|
|
95
|
-
const { body, ...meta } = result;
|
|
96
|
-
return {
|
|
97
|
-
content: [{ type: "text" as const, text: body }],
|
|
98
|
-
structuredContent: { ...meta },
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
100
|
return {
|
|
102
101
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
103
102
|
structuredContent: { ...result },
|
|
@@ -394,7 +393,6 @@ export async function startServer(opts?: {
|
|
|
394
393
|
JSON.stringify({
|
|
395
394
|
config_read: true,
|
|
396
395
|
config_write: !isExternallyManaged,
|
|
397
|
-
calibration: false,
|
|
398
396
|
persistence: isExternallyManaged
|
|
399
397
|
? "externally_managed"
|
|
400
398
|
: "writable",
|
|
@@ -481,18 +479,6 @@ export async function startServer(opts?: {
|
|
|
481
479
|
});
|
|
482
480
|
}
|
|
483
481
|
|
|
484
|
-
if (url.pathname.startsWith("/admin/v1/calibrations")) {
|
|
485
|
-
return new Response(
|
|
486
|
-
JSON.stringify({
|
|
487
|
-
error: "not_implemented",
|
|
488
|
-
message:
|
|
489
|
-
"Remote calibration is not implemented in this release. " +
|
|
490
|
-
"Run `skillmux calibrate` against a local target.",
|
|
491
|
-
}),
|
|
492
|
-
{ status: 501, headers },
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
482
|
return new Response("Not Found", { status: 404, headers });
|
|
497
483
|
}
|
|
498
484
|
|
package/src/stats.ts
CHANGED
|
@@ -5,22 +5,32 @@ export const SINCE_PATTERN = /^(\d+[hdwmy]|\d{4}-\d{2}-\d{2}([T ].+)?)$/;
|
|
|
5
5
|
|
|
6
6
|
export interface SkillStat {
|
|
7
7
|
skill_id: string;
|
|
8
|
-
matched_count: number;
|
|
9
8
|
candidate_count: number;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export interface
|
|
11
|
+
export interface EmptyShortlistQuery {
|
|
13
12
|
query: string;
|
|
14
13
|
count: number;
|
|
15
14
|
}
|
|
16
15
|
|
|
16
|
+
export interface RetrievalTotals {
|
|
17
|
+
exact: number;
|
|
18
|
+
reranked: number;
|
|
19
|
+
hybrid: number;
|
|
20
|
+
lexical: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export interface StatsResponse {
|
|
18
24
|
since: string;
|
|
19
25
|
until: string;
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
total_requests: number;
|
|
27
|
+
empty_shortlist_count: number;
|
|
28
|
+
empty_shortlist_rate: number;
|
|
29
|
+
retrieval_totals: RetrievalTotals;
|
|
30
|
+
degraded_count: number;
|
|
31
|
+
average_latency_ms: number;
|
|
22
32
|
skills: SkillStat[];
|
|
23
|
-
|
|
33
|
+
top_empty_shortlist_queries: EmptyShortlistQuery[];
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
const RELATIVE_WINDOW = /^(\d+)([hdwmy])$/;
|
|
@@ -47,58 +57,79 @@ export function parseSince(since: string, now: Date = new Date()): Date {
|
|
|
47
57
|
return parsed;
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
function compareCodeUnits(a: string, b: string): number {
|
|
61
|
+
if (a < b) return -1;
|
|
62
|
+
if (a > b) return 1;
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsResponse {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!stat) {
|
|
58
|
-
stat = { matched_count: 0, candidate_count: 0 };
|
|
59
|
-
skillStats.set(skillId, stat);
|
|
60
|
-
}
|
|
61
|
-
return stat;
|
|
62
|
-
}
|
|
67
|
+
const retrieval_totals: RetrievalTotals = { exact: 0, reranked: 0, hybrid: 0, lexical: 0 };
|
|
68
|
+
const skillCounts = new Map<string, number>();
|
|
69
|
+
const emptyShortlistCounts = new Map<string, number>();
|
|
70
|
+
let empty_shortlist_count = 0;
|
|
71
|
+
let degraded_count = 0;
|
|
72
|
+
let total_latency_ms = 0;
|
|
63
73
|
|
|
64
74
|
for (const row of rows) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const seenInRow = new Set<string>();
|
|
68
|
-
for (const candidate of row.candidates) {
|
|
69
|
-
if (seenInRow.has(candidate.skill_id)) continue;
|
|
70
|
-
seenInRow.add(candidate.skill_id);
|
|
71
|
-
statFor(candidate.skill_id).candidate_count++;
|
|
75
|
+
if (row.retrieval in retrieval_totals) {
|
|
76
|
+
retrieval_totals[row.retrieval]++;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
if (row.
|
|
75
|
-
|
|
79
|
+
if (row.degraded_from || row.degradation_reason) {
|
|
80
|
+
degraded_count++;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
total_latency_ms += row.latency_ms;
|
|
84
|
+
|
|
85
|
+
if (row.candidates.length === 0) {
|
|
86
|
+
empty_shortlist_count++;
|
|
87
|
+
emptyShortlistCounts.set(row.query, (emptyShortlistCounts.get(row.query) ?? 0) + 1);
|
|
88
|
+
} else {
|
|
89
|
+
const seenInRow = new Set<string>();
|
|
90
|
+
for (const candidate of row.candidates) {
|
|
91
|
+
if (!candidate.skill_id) continue;
|
|
92
|
+
if (seenInRow.has(candidate.skill_id)) continue;
|
|
93
|
+
seenInRow.add(candidate.skill_id);
|
|
94
|
+
skillCounts.set(candidate.skill_id, (skillCounts.get(candidate.skill_id) ?? 0) + 1);
|
|
95
|
+
}
|
|
80
96
|
}
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
const
|
|
84
|
-
const
|
|
99
|
+
const total_requests = rows.length;
|
|
100
|
+
const empty_shortlist_rate = total_requests > 0 ? empty_shortlist_count / total_requests : 0;
|
|
101
|
+
const average_latency_ms = total_requests > 0 ? total_latency_ms / total_requests : 0;
|
|
85
102
|
|
|
86
|
-
const skills = [...
|
|
87
|
-
.map(([skill_id,
|
|
88
|
-
.sort((a, b) =>
|
|
103
|
+
const skills: SkillStat[] = [...skillCounts.entries()]
|
|
104
|
+
.map(([skill_id, candidate_count]) => ({ skill_id, candidate_count }))
|
|
105
|
+
.sort((a, b) => {
|
|
106
|
+
if (b.candidate_count !== a.candidate_count) {
|
|
107
|
+
return b.candidate_count - a.candidate_count;
|
|
108
|
+
}
|
|
109
|
+
return compareCodeUnits(a.skill_id, b.skill_id);
|
|
110
|
+
});
|
|
89
111
|
|
|
90
|
-
const
|
|
112
|
+
const top_empty_shortlist_queries: EmptyShortlistQuery[] = [...emptyShortlistCounts.entries()]
|
|
91
113
|
.map(([query, count]) => ({ query, count }))
|
|
92
|
-
.sort((a, b) =>
|
|
114
|
+
.sort((a, b) => {
|
|
115
|
+
if (b.count !== a.count) {
|
|
116
|
+
return b.count - a.count;
|
|
117
|
+
}
|
|
118
|
+
return compareCodeUnits(a.query, b.query);
|
|
119
|
+
})
|
|
93
120
|
.slice(0, 20);
|
|
94
121
|
|
|
95
122
|
return {
|
|
96
123
|
since: since.toISOString(),
|
|
97
124
|
until: until.toISOString(),
|
|
98
|
-
|
|
99
|
-
|
|
125
|
+
total_requests,
|
|
126
|
+
empty_shortlist_count,
|
|
127
|
+
empty_shortlist_rate,
|
|
128
|
+
retrieval_totals,
|
|
129
|
+
degraded_count,
|
|
130
|
+
average_latency_ms,
|
|
100
131
|
skills,
|
|
101
|
-
|
|
132
|
+
top_empty_shortlist_queries,
|
|
102
133
|
};
|
|
103
134
|
}
|
|
104
135
|
|
|
@@ -106,27 +137,60 @@ interface AuditTableRow {
|
|
|
106
137
|
id: number;
|
|
107
138
|
ts: string;
|
|
108
139
|
query: string;
|
|
109
|
-
outcome: AuditRow["outcome"];
|
|
110
140
|
retrieval: AuditRow["retrieval"];
|
|
141
|
+
degraded_from: string | null;
|
|
142
|
+
degradation_reason: string | null;
|
|
111
143
|
candidates: string;
|
|
112
|
-
selected_skill_id: string | null;
|
|
113
144
|
latency_ms: number;
|
|
114
145
|
}
|
|
115
146
|
|
|
116
147
|
export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
|
|
117
148
|
const rows = db
|
|
118
|
-
.query(
|
|
149
|
+
.query(
|
|
150
|
+
"SELECT id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC",
|
|
151
|
+
)
|
|
119
152
|
.all(sinceIso) as AuditTableRow[];
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
153
|
+
|
|
154
|
+
return rows.map((row) => {
|
|
155
|
+
let parsed: unknown;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(row.candidates);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`Failed to parse candidates JSON for audit row ${row.id}`);
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(parsed)) {
|
|
162
|
+
throw new Error(`Invalid candidates JSON for audit row ${row.id}: expected array, got ${typeof parsed}`);
|
|
163
|
+
}
|
|
164
|
+
const candidates: AuditCandidate[] = parsed.map((c: any, index: number) => {
|
|
165
|
+
if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.skill_id !== "string") {
|
|
166
|
+
throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid skill_id`);
|
|
167
|
+
}
|
|
168
|
+
const score = c.score;
|
|
169
|
+
if (score !== null && (typeof score !== "number" || !Number.isFinite(score))) {
|
|
170
|
+
throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid score`);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
skill_id: c.skill_id,
|
|
174
|
+
score,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result: AuditRow = {
|
|
179
|
+
id: row.id,
|
|
180
|
+
ts: row.ts,
|
|
181
|
+
query: row.query,
|
|
182
|
+
retrieval: row.retrieval,
|
|
183
|
+
candidates,
|
|
184
|
+
latency_ms: row.latency_ms,
|
|
185
|
+
};
|
|
186
|
+
if (row.degraded_from !== null && row.degraded_from !== undefined) {
|
|
187
|
+
result.degraded_from = row.degraded_from as AuditRow["degraded_from"];
|
|
188
|
+
}
|
|
189
|
+
if (row.degradation_reason !== null && row.degradation_reason !== undefined) {
|
|
190
|
+
result.degradation_reason = row.degradation_reason as AuditRow["degradation_reason"];
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
});
|
|
130
194
|
}
|
|
131
195
|
|
|
132
196
|
export function getStats(db: Database, since: string, now: Date = new Date()): StatsResponse {
|
|
@@ -139,8 +203,13 @@ export function renderStatsText(stats: StatsResponse): string {
|
|
|
139
203
|
const lines: string[] = [];
|
|
140
204
|
lines.push(`window: ${stats.since} .. ${stats.until}`);
|
|
141
205
|
lines.push(
|
|
142
|
-
`
|
|
143
|
-
`
|
|
206
|
+
`requests: total=${stats.total_requests} empty_shortlist=${stats.empty_shortlist_count} ` +
|
|
207
|
+
`(empty_shortlist_rate=${stats.empty_shortlist_rate.toFixed(3)}) ` +
|
|
208
|
+
`degraded=${stats.degraded_count} avg_latency_ms=${stats.average_latency_ms.toFixed(1)}`,
|
|
209
|
+
);
|
|
210
|
+
lines.push(
|
|
211
|
+
`retrieval: exact=${stats.retrieval_totals.exact} reranked=${stats.retrieval_totals.reranked} ` +
|
|
212
|
+
`hybrid=${stats.retrieval_totals.hybrid} lexical=${stats.retrieval_totals.lexical}`,
|
|
144
213
|
);
|
|
145
214
|
|
|
146
215
|
lines.push("skills:");
|
|
@@ -148,15 +217,15 @@ export function renderStatsText(stats: StatsResponse): string {
|
|
|
148
217
|
lines.push(" (none)");
|
|
149
218
|
} else {
|
|
150
219
|
for (const skill of stats.skills) {
|
|
151
|
-
lines.push(` ${skill.skill_id}
|
|
220
|
+
lines.push(` ${skill.skill_id} candidate=${skill.candidate_count}`);
|
|
152
221
|
}
|
|
153
222
|
}
|
|
154
223
|
|
|
155
|
-
lines.push("top
|
|
156
|
-
if (stats.
|
|
224
|
+
lines.push("top empty shortlist queries:");
|
|
225
|
+
if (stats.top_empty_shortlist_queries.length === 0) {
|
|
157
226
|
lines.push(" (none)");
|
|
158
227
|
} else {
|
|
159
|
-
for (const entry of stats.
|
|
228
|
+
for (const entry of stats.top_empty_shortlist_queries) {
|
|
160
229
|
lines.push(` "${entry.query}" (${entry.count})`);
|
|
161
230
|
}
|
|
162
231
|
}
|