@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.
@@ -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
- // Short-circuit: exact match on skill_id, title, or alias (First Principles #1)
374
- const exactMatch = findExactMatch(db, input.query);
375
- if (exactMatch) {
376
- const delivery = await deliverSkill(db, config, exactMatch.skill_id);
377
- const result: ResolveResult = {
378
- outcome: "matched",
379
- retrieval: "exact",
380
- skill_id: exactMatch.skill_id,
381
- title: delivery.title,
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 decision = decideRetrievalResult(config, retrievalResult);
408
-
409
- let result: ResolveResult;
410
- if (decision.outcome === "matched") {
411
- const delivery = await deliverSkill(db, config, decision.skill_id);
412
- result = {
413
- outcome: "matched",
414
- retrieval: "reranked",
415
- ...(retrievalResult.degraded_from
416
- ? {
417
- degraded_from: retrievalResult.degraded_from,
418
- degradation_reason: retrievalResult.degradation_reason,
419
- }
420
- : {}),
421
- skill_id: decision.skill_id,
422
- title: delivery.title,
423
- content_sha256: delivery.content_sha256,
424
- score: decision.score,
425
- margin: decision.margin,
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: rankedCandidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
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: RankedCandidate[];
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 and rerank it once without applying
546
- * decision thresholds, synchronizing the vault before reading the index.
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
- if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
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 the most relevant skill in the vault. " +
78
- "Returns outcome matched (skill delivered inline), ambiguous (shortlist pick one, then call fetch_skill), " +
79
- "or no_match (proceed under your normal workflow).",
80
- inputSchema: { query: z.string().min(1) },
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 NoMatchQuery {
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
- outcome_totals: { matched: number; ambiguous: number; no_match: number };
21
- ambiguous_rate: number;
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
- top_no_match_queries: NoMatchQuery[];
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 outcome_totals = { matched: 0, ambiguous: 0, no_match: 0 };
52
- const skillStats = new Map<string, { matched_count: number; candidate_count: number }>();
53
- const noMatchCounts = new Map<string, number>();
54
-
55
- function statFor(skillId: string) {
56
- let stat = skillStats.get(skillId);
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
- outcome_totals[row.outcome]++;
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.outcome === "matched" && row.selected_skill_id) {
75
- statFor(row.selected_skill_id).matched_count++;
79
+ if (row.degraded_from || row.degradation_reason) {
80
+ degraded_count++;
76
81
  }
77
82
 
78
- if (row.outcome === "no_match") {
79
- noMatchCounts.set(row.query, (noMatchCounts.get(row.query) ?? 0) + 1);
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 total = outcome_totals.matched + outcome_totals.ambiguous + outcome_totals.no_match;
84
- const ambiguous_rate = total > 0 ? outcome_totals.ambiguous / total : 0;
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 = [...skillStats.entries()]
87
- .map(([skill_id, stat]) => ({ skill_id, ...stat }))
88
- .sort((a, b) => b.matched_count - a.matched_count);
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 top_no_match_queries = [...noMatchCounts.entries()]
112
+ const top_empty_shortlist_queries: EmptyShortlistQuery[] = [...emptyShortlistCounts.entries()]
91
113
  .map(([query, count]) => ({ query, count }))
92
- .sort((a, b) => b.count - a.count)
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
- outcome_totals,
99
- ambiguous_rate,
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
- top_no_match_queries,
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("SELECT id, ts, query, outcome, retrieval, candidates, selected_skill_id, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC")
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
- return rows.map((row) => ({
121
- id: row.id,
122
- ts: row.ts,
123
- query: row.query,
124
- outcome: row.outcome,
125
- retrieval: row.retrieval,
126
- candidates: JSON.parse(row.candidates) as AuditCandidate[],
127
- selected_skill_id: row.selected_skill_id,
128
- latency_ms: row.latency_ms,
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
- `outcomes: matched=${stats.outcome_totals.matched} ambiguous=${stats.outcome_totals.ambiguous} ` +
143
- `no_match=${stats.outcome_totals.no_match} (ambiguous_rate=${stats.ambiguous_rate.toFixed(3)})`,
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} matched=${skill.matched_count} candidate=${skill.candidate_count}`);
220
+ lines.push(` ${skill.skill_id} candidate=${skill.candidate_count}`);
152
221
  }
153
222
  }
154
223
 
155
- lines.push("top no_match queries:");
156
- if (stats.top_no_match_queries.length === 0) {
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.top_no_match_queries) {
228
+ for (const entry of stats.top_empty_shortlist_queries) {
160
229
  lines.push(` "${entry.query}" (${entry.count})`);
161
230
  }
162
231
  }