@klhapp/skillmux 1.1.0 → 1.2.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 +10 -0
- package/README.md +6 -1
- package/config.remote.example.toml +3 -1
- package/docs/calibration.md +106 -0
- package/docs/configuration.md +11 -2
- package/package.json +2 -1
- package/src/adapters.ts +33 -40
- package/src/calibrate.ts +165 -9
- package/src/cli.ts +12 -4
- package/src/dataset-generator.ts +75 -96
- package/src/server.ts +10 -70
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0](https://github.com/klhq/skillmux/compare/v1.1.0...v1.2.0) (2026-07-28)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* **calibration:** finish remote contract and docs ([f696da2](https://github.com/klhq/skillmux/commit/f696da25094c19537b1291f694114f8e4052ea69))
|
|
14
|
+
* **calibration:** improve dataset quality ([#88](https://github.com/klhq/skillmux/issues/88)) ([e99b764](https://github.com/klhq/skillmux/commit/e99b764ce90b7432fca0b01c22560ba73b401dd2))
|
|
15
|
+
* **calibration:** require labelled audit feedback ([16d9ec8](https://github.com/klhq/skillmux/commit/16d9ec81acbdca0e42a401b9e28f9e3814b3e0ab))
|
|
16
|
+
* **calibration:** require labelled audit feedback ([#89](https://github.com/klhq/skillmux/issues/89)) ([a7a99e2](https://github.com/klhq/skillmux/commit/a7a99e2d82e619255135b1e941c69c8d6653012c))
|
|
17
|
+
|
|
8
18
|
## [1.1.0](https://github.com/klhq/skillmux/compare/v1.0.1...v1.1.0) (2026-07-28)
|
|
9
19
|
|
|
10
20
|
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Built for agents that lack native skill triggering (Goose recipe workers, openco
|
|
|
14
14
|
- [Docker Usage](#docker-usage)
|
|
15
15
|
- [Configuration](#configuration) — inference modes, security scanning, installing skills, env vars
|
|
16
16
|
- [CLI & Automation](docs/cli.md) — context management, remote target resolution, policy calibration, JSON envelopes
|
|
17
|
+
- [Policy Calibration](docs/calibration.md) — labelled datasets, certification, apply lifecycle, and reference profile
|
|
17
18
|
- [Benchmarks & Evaluation](#benchmarks--evaluation)
|
|
18
19
|
- [FAQ & Troubleshooting](#faq--troubleshooting)
|
|
19
20
|
- [Guarantees](#guarantees)
|
|
@@ -546,7 +547,11 @@ bun run src/cli.ts eval
|
|
|
546
547
|
# hybrid recall@5: 1.000
|
|
547
548
|
```
|
|
548
549
|
|
|
549
|
-
Custom policy calibration can also be performed against
|
|
550
|
+
Custom policy calibration can also be performed against reviewed,
|
|
551
|
+
domain-specific query datasets using `skillmux calibrate`. See the
|
|
552
|
+
[calibration guide](docs/calibration.md) for the full operator lifecycle and
|
|
553
|
+
the [CLI reference](docs/cli.md#policy-calibration-skillmux-calibrate) for
|
|
554
|
+
command syntax.
|
|
550
555
|
|
|
551
556
|
## FAQ & Troubleshooting
|
|
552
557
|
|
|
@@ -18,7 +18,9 @@ endpoint = "https://reranker.example.com/v1/rerank"
|
|
|
18
18
|
model = "your-reranker-model"
|
|
19
19
|
api_key_env = "RERANKER_API_KEY"
|
|
20
20
|
|
|
21
|
-
#
|
|
21
|
+
# Optional. Without these values, reranked results remain ambiguous.
|
|
22
|
+
# Calibrate locally against your corpus before enabling automatic matches.
|
|
23
|
+
# See docs/calibration.md; these example values are not universal defaults.
|
|
22
24
|
[inference.thresholds]
|
|
23
25
|
match_score = 0.90
|
|
24
26
|
match_margin = 0.30
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Policy calibration
|
|
2
|
+
|
|
3
|
+
Calibration selects the three reranker-score thresholds that turn an ordered
|
|
4
|
+
shortlist into `matched`, `ambiguous`, or `no_match`. It is an operator action,
|
|
5
|
+
not background learning, and it currently runs only against a local Skillmux
|
|
6
|
+
target.
|
|
7
|
+
|
|
8
|
+
## Lifecycle
|
|
9
|
+
|
|
10
|
+
The complete workflow is:
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
install CLI → configure vault/index/embedding/reranker → obtain labelled dataset
|
|
14
|
+
→ calibrate run → review calibrate show RUN_ID → calibrate apply RUN_ID
|
|
15
|
+
→ live-reloaded policy handles subsequent requests
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
First configure and index the same vault, embedding model, and reranker that
|
|
19
|
+
will serve requests. Supply a reviewed dataset, or generate a starting point
|
|
20
|
+
and review every label:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
skillmux calibrate generate-dataset --out ./eval/queries.json
|
|
24
|
+
skillmux calibrate run --dataset ./eval/queries.json
|
|
25
|
+
skillmux calibrate show RUN_ID
|
|
26
|
+
skillmux calibrate apply RUN_ID
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Skillmux retrieves candidates and reranks exactly once for each evaluation
|
|
30
|
+
query. It caches those observations, searches thresholds on the `tune` split,
|
|
31
|
+
then certifies the selected policy on the frozen `test` split. Calibration
|
|
32
|
+
starts only when an operator invokes `calibrate run`.
|
|
33
|
+
|
|
34
|
+
The operator owns the labels: supply or review the cases, start the run,
|
|
35
|
+
inspect its evidence, and explicitly apply an acceptable result. A successful
|
|
36
|
+
run never changes live thresholds by itself.
|
|
37
|
+
|
|
38
|
+
## Reading a run
|
|
39
|
+
|
|
40
|
+
A `run_id` identifies one immutable calibration attempt and its evidence.
|
|
41
|
+
`calibrate show RUN_ID` is read-only. It reports:
|
|
42
|
+
|
|
43
|
+
- selected thresholds and tune/test metrics;
|
|
44
|
+
- auto-match precision confidence and sample counts;
|
|
45
|
+
- retrieval and delivered-shortlist recall;
|
|
46
|
+
- a closed failure reason when certification fails;
|
|
47
|
+
- reranker, embedding, corpus, and dataset fingerprints;
|
|
48
|
+
- dataset provenance and the number of human-labelled cases; and
|
|
49
|
+
- the attempt count for the dataset hash.
|
|
50
|
+
|
|
51
|
+
`calibrate apply RUN_ID` accepts only a completed, test-certified run. It
|
|
52
|
+
rechecks the reranker fingerprint, rejects thresholds masked by environment
|
|
53
|
+
variables, atomically updates the TOML file, and lets the config watcher
|
|
54
|
+
activate the new snapshot.
|
|
55
|
+
|
|
56
|
+
## Dataset responsibilities
|
|
57
|
+
|
|
58
|
+
Each case needs a query, expected outcome, relevant skill ids, and a fixed
|
|
59
|
+
`tune` or `test` split. Unknown skill ids are rejected. Keep a skill entirely
|
|
60
|
+
within one split so the test set measures generalization rather than memorized
|
|
61
|
+
skill wording.
|
|
62
|
+
|
|
63
|
+
Generated datasets are scaffolding, not ground truth. Review paraphrases,
|
|
64
|
+
near-miss negatives, and ambiguous cases before using them for certification.
|
|
65
|
+
Audit-derived cases require an explicit human label and provenance. Raw audit
|
|
66
|
+
queries are excluded unless the importer is deliberately configured to retain
|
|
67
|
+
them.
|
|
68
|
+
|
|
69
|
+
## When to recalibrate
|
|
70
|
+
|
|
71
|
+
Re-run calibration after a material change to the corpus, embedding or
|
|
72
|
+
retrieval behavior, reranker adapter or model, or after collecting enough new
|
|
73
|
+
human-labelled feedback. Do not recalibrate per user request. Every rerun gets
|
|
74
|
+
a new `run_id`; the active policy remains unchanged until one is applied.
|
|
75
|
+
|
|
76
|
+
## Local and remote targets
|
|
77
|
+
|
|
78
|
+
Calibration is local-only in this release. Local commands operate on the
|
|
79
|
+
configured local vault, index, inference endpoints, dataset path, evidence
|
|
80
|
+
database, and TOML file. Human output always prints `Target: local`; JSON output
|
|
81
|
+
uses `"target": "local"`.
|
|
82
|
+
|
|
83
|
+
Remote servers advertise `"calibration": false`. Every
|
|
84
|
+
`/admin/v1/calibrations` route returns HTTP `501` with
|
|
85
|
+
`error: "not_implemented"`, and the CLI rejects remote calibration before
|
|
86
|
+
uploading or claiming to execute a local dataset path. This also prevents raw
|
|
87
|
+
evaluation queries from being exposed through the admin API.
|
|
88
|
+
|
|
89
|
+
## Reference starting profile
|
|
90
|
+
|
|
91
|
+
Reranker scores are not portable across models, adapters, model revisions, or
|
|
92
|
+
corpora. The profile below is published only to make the checked-in BGE example
|
|
93
|
+
concrete; it is not a certified substitute for calibration.
|
|
94
|
+
|
|
95
|
+
| Model | Adapter | `match_score` | `match_margin` | `candidate_floor` |
|
|
96
|
+
|---|---|---:|---:|---:|
|
|
97
|
+
| `BAAI/bge-reranker-v2-m3` | `jina-v1` | `0.90` | `0.20` | `0.40` |
|
|
98
|
+
|
|
99
|
+
Provenance: the small synthetic corpus and labelled decision cases in
|
|
100
|
+
[`tests/router-core.spec.test.ts`](../tests/router-core.spec.test.ts), with the
|
|
101
|
+
wire contract captured by
|
|
102
|
+
[`tests/fixtures/reranker/jina-v1-request.json`](../tests/fixtures/reranker/jina-v1-request.json).
|
|
103
|
+
That fixture is below the default 30-auto-match certification minimum, so the
|
|
104
|
+
values are a smoke-test/reference profile, not a completed calibration run.
|
|
105
|
+
Run the lifecycle above against the deployment's real corpus before enabling
|
|
106
|
+
automatic matches in production.
|
package/docs/configuration.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Skillmux defaults to FTS5 plus local GTE-small semantic retrieval. Most users need no config file.
|
|
4
4
|
|
|
5
|
-
For detailed CLI command reference, target resolution,
|
|
5
|
+
For detailed CLI command reference, target resolution, and automation
|
|
6
|
+
envelopes, see [`docs/cli.md`](cli.md). For labelled datasets, threshold
|
|
7
|
+
certification, reference values, and the apply lifecycle, see
|
|
8
|
+
[`docs/calibration.md`](calibration.md).
|
|
6
9
|
|
|
7
10
|
## Machine config bootstrap
|
|
8
11
|
|
|
@@ -105,7 +108,13 @@ candidate list returned to the calling LLM after retrieval, reranking, and
|
|
|
105
108
|
threshold filtering. It does not change retrieval depth or the matched,
|
|
106
109
|
ambiguous, or no-match classification.
|
|
107
110
|
|
|
108
|
-
Reranker thresholds have no universal default because score distributions are
|
|
111
|
+
Reranker thresholds have no universal default because score distributions are
|
|
112
|
+
model-specific. Without `inference.thresholds`, Skillmux still uses the
|
|
113
|
+
reranker to order candidates but keeps outcomes ambiguous rather than
|
|
114
|
+
auto-matching. Use `skillmux calibrate run` to select
|
|
115
|
+
`match_score`, `match_margin`, and `candidate_floor`, then explicitly apply the
|
|
116
|
+
certified run. The [calibration guide](calibration.md) also publishes a
|
|
117
|
+
clearly-scoped BGE reference profile for smoke tests.
|
|
109
118
|
|
|
110
119
|
## HTTP server
|
|
111
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klhapp/skillmux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Local read-only MCP server routing natural-language task queries to skills in a SKILL.md vault, with zero-loss delivery",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"docs/schema.json",
|
|
26
26
|
"docs/configuration.md",
|
|
27
|
+
"docs/calibration.md",
|
|
27
28
|
"docs/releasing.md",
|
|
28
29
|
"README.md",
|
|
29
30
|
"LICENSE",
|
package/src/adapters.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { applyCalibrationRun, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, type CalibrationResult } from "./calibrate";
|
|
3
|
+
import { applyCalibrationRun, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, summarizeDatasetProvenance, type CalibrationResult } from "./calibrate";
|
|
4
4
|
import { createClients } from "./clients";
|
|
5
5
|
import { DEFAULT_CONFIG_PATH, embeddingFingerprint, expandHome, loadConfig, rerankerFingerprint } from "./config";
|
|
6
6
|
import { openIndex } from "./db";
|
|
@@ -139,7 +139,19 @@ export class LocalAdapter implements TargetAdapter {
|
|
|
139
139
|
}): Promise<{ run_id?: string; result?: CalibrationResult }> {
|
|
140
140
|
const config = await loadConfig(this.configPath);
|
|
141
141
|
const datasetFile = opts?.datasetPath ?? join(expandHome(config.state_dir), "queries.json");
|
|
142
|
-
const
|
|
142
|
+
const indexDb = openIndex(expandHome(config.state_dir));
|
|
143
|
+
let indexedSkills: Array<{ skill_id: string; content_sha256: string }>;
|
|
144
|
+
try {
|
|
145
|
+
indexedSkills = indexDb
|
|
146
|
+
.query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
|
|
147
|
+
.all() as Array<{ skill_id: string; content_sha256: string }>;
|
|
148
|
+
} finally {
|
|
149
|
+
indexDb.close();
|
|
150
|
+
}
|
|
151
|
+
const cases = loadDecisionCasesFromFile(
|
|
152
|
+
datasetFile,
|
|
153
|
+
indexedSkills.map((skill) => skill.skill_id),
|
|
154
|
+
);
|
|
143
155
|
const clients = createClients(config);
|
|
144
156
|
configure({ config, clients });
|
|
145
157
|
const result = await runCalibration({
|
|
@@ -168,18 +180,9 @@ export class LocalAdapter implements TargetAdapter {
|
|
|
168
180
|
throw new Error("A configured remote reranker is required to record calibration.");
|
|
169
181
|
}
|
|
170
182
|
const datasetText = await Bun.file(datasetFile).text();
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const rows = indexDb
|
|
175
|
-
.query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
|
|
176
|
-
.all();
|
|
177
|
-
corpusFingerprint =
|
|
178
|
-
"vault:" +
|
|
179
|
-
createHash("sha256").update(JSON.stringify(rows)).digest("hex");
|
|
180
|
-
} finally {
|
|
181
|
-
indexDb.close();
|
|
182
|
-
}
|
|
183
|
+
const corpusFingerprint =
|
|
184
|
+
"vault:" +
|
|
185
|
+
createHash("sha256").update(JSON.stringify(indexedSkills)).digest("hex");
|
|
183
186
|
const runId = `run_${crypto.randomUUID()}`;
|
|
184
187
|
const db = openCalibrateDb(expandHome(config.state_dir));
|
|
185
188
|
try {
|
|
@@ -191,6 +194,7 @@ export class LocalAdapter implements TargetAdapter {
|
|
|
191
194
|
embedding_fingerprint: embeddingFingerprint(config),
|
|
192
195
|
corpus_fingerprint: corpusFingerprint,
|
|
193
196
|
dataset_hash: createHash("sha256").update(datasetText).digest("hex"),
|
|
197
|
+
dataset_provenance: summarizeDatasetProvenance(cases),
|
|
194
198
|
candidate_limit: config.thresholds.candidate_limit,
|
|
195
199
|
min_auto_match_precision: opts?.minAutoMatchPrecision ?? 0.99,
|
|
196
200
|
min_auto_match_count: opts?.minAutoMatchCount ?? 30,
|
|
@@ -420,41 +424,30 @@ export class RemoteAdapter implements TargetAdapter {
|
|
|
420
424
|
minDeliveredShortlistRecallAtK?: number;
|
|
421
425
|
minAutoMatchCount?: number;
|
|
422
426
|
}): Promise<{ run_id?: string; result?: CalibrationResult }> {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
headers: { "Content-Type": "application/json" },
|
|
426
|
-
body: JSON.stringify({
|
|
427
|
-
dataset_path: opts?.datasetPath,
|
|
428
|
-
min_auto_match_precision: opts?.minAutoMatchPrecision,
|
|
429
|
-
min_retrieval_recall_at_k: opts?.minRetrievalRecallAtK,
|
|
430
|
-
min_delivered_shortlist_recall_at_k: opts?.minDeliveredShortlistRecallAtK,
|
|
431
|
-
min_auto_match_count: opts?.minAutoMatchCount,
|
|
432
|
-
}),
|
|
433
|
-
});
|
|
434
|
-
if (status !== 202) {
|
|
435
|
-
throw new Error(`Remote calibration start failed (${status}): ${data?.message || data}`);
|
|
436
|
-
}
|
|
437
|
-
return data;
|
|
427
|
+
void opts;
|
|
428
|
+
throw this.remoteCalibrationNotImplemented();
|
|
438
429
|
}
|
|
439
430
|
|
|
440
431
|
async calibrateList(): Promise<any[]> {
|
|
441
|
-
|
|
442
|
-
if (status !== 200) throw new Error(`Remote calibration list failed (${status}): ${data?.message || data}`);
|
|
443
|
-
return data;
|
|
432
|
+
throw this.remoteCalibrationNotImplemented();
|
|
444
433
|
}
|
|
445
434
|
|
|
446
435
|
async calibrateShow(runId: string): Promise<any> {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
return data;
|
|
436
|
+
void runId;
|
|
437
|
+
throw this.remoteCalibrationNotImplemented();
|
|
450
438
|
}
|
|
451
439
|
|
|
452
440
|
async calibrateApply(runId: string): Promise<any> {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
441
|
+
void runId;
|
|
442
|
+
throw this.remoteCalibrationNotImplemented();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private remoteCalibrationNotImplemented(): CliError {
|
|
446
|
+
return new CliError(
|
|
447
|
+
`Remote calibration is not implemented for target ${this.serverUrl}; ` +
|
|
448
|
+
"run `skillmux calibrate` against a local target.",
|
|
449
|
+
2,
|
|
450
|
+
);
|
|
458
451
|
}
|
|
459
452
|
}
|
|
460
453
|
|
package/src/calibrate.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { decideResolveOutcome } from "./decision";
|
|
6
|
-
import type { RankedCandidate } from "./types";
|
|
6
|
+
import type { AuditRow, RankedCandidate } from "./types";
|
|
7
7
|
|
|
8
8
|
export { generateDataset, type GenerateDatasetOptions } from "./dataset-generator";
|
|
9
9
|
|
|
@@ -15,11 +15,21 @@ export { generateDataset, type GenerateDatasetOptions } from "./dataset-generato
|
|
|
15
15
|
export type DecisionSplit = "tune" | "test";
|
|
16
16
|
export type DecisionOutcome = "matched" | "ambiguous" | "no_match";
|
|
17
17
|
|
|
18
|
+
export interface DecisionCaseProvenance {
|
|
19
|
+
version: 1;
|
|
20
|
+
source: "authored" | "audit_import";
|
|
21
|
+
review_status: "human_labelled" | "unreviewed";
|
|
22
|
+
query_storage: "raw" | "redacted";
|
|
23
|
+
audit_id?: number;
|
|
24
|
+
labelled_at?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export interface DecisionCase {
|
|
19
28
|
query: string;
|
|
20
29
|
split: DecisionSplit;
|
|
21
30
|
expected_outcome: DecisionOutcome;
|
|
22
31
|
relevant_skill_ids: string[];
|
|
32
|
+
provenance?: DecisionCaseProvenance;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
// ---------------------------------------------------------------------------
|
|
@@ -31,6 +41,14 @@ const rawCaseSchema = z.object({
|
|
|
31
41
|
split: z.enum(["tune", "test"]),
|
|
32
42
|
expected_outcome: z.enum(["matched", "ambiguous", "no_match"]),
|
|
33
43
|
relevant_skill_ids: z.array(z.string()),
|
|
44
|
+
provenance: z.object({
|
|
45
|
+
version: z.literal(1),
|
|
46
|
+
source: z.enum(["authored", "audit_import"]),
|
|
47
|
+
review_status: z.enum(["human_labelled", "unreviewed"]),
|
|
48
|
+
query_storage: z.enum(["raw", "redacted"]),
|
|
49
|
+
audit_id: z.number().int().positive().optional(),
|
|
50
|
+
labelled_at: z.string().datetime().optional(),
|
|
51
|
+
}).strict().optional(),
|
|
34
52
|
}).strict();
|
|
35
53
|
|
|
36
54
|
type RawCase = z.infer<typeof rawCaseSchema>;
|
|
@@ -45,6 +63,25 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
|
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
const { expected_outcome, relevant_skill_ids } = raw;
|
|
66
|
+
const provenance: DecisionCaseProvenance = raw.provenance ?? {
|
|
67
|
+
version: 1,
|
|
68
|
+
source: "authored",
|
|
69
|
+
review_status: "human_labelled",
|
|
70
|
+
query_storage: "raw",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (provenance.source === "audit_import") {
|
|
74
|
+
if (provenance.audit_id === undefined) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Validation error at case ${idx}: imported field "provenance.audit_id" is required`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (provenance.review_status !== "human_labelled" || !provenance.labelled_at) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Validation error at case ${idx}: imported audit case is unreviewed; human label and "provenance.labelled_at" are required for certification`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
48
85
|
|
|
49
86
|
if (expected_outcome === "matched") {
|
|
50
87
|
if (relevant_skill_ids.length !== 1) {
|
|
@@ -67,7 +104,85 @@ function validateCase(raw: RawCase, idx: number): DecisionCase {
|
|
|
67
104
|
}
|
|
68
105
|
}
|
|
69
106
|
|
|
70
|
-
return raw
|
|
107
|
+
return { ...raw, provenance };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AuditFeedbackLabel {
|
|
111
|
+
split: DecisionSplit;
|
|
112
|
+
expected_outcome: DecisionOutcome;
|
|
113
|
+
relevant_skill_ids: string[];
|
|
114
|
+
labelled_at: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type AuditQueryPrivacy =
|
|
118
|
+
| { include_raw_query: true }
|
|
119
|
+
| { include_raw_query: false; redacted_query: string };
|
|
120
|
+
|
|
121
|
+
/** Import an audit outcome only after a separate human label is supplied. */
|
|
122
|
+
export function importLabelledAuditCase(
|
|
123
|
+
audit: AuditRow,
|
|
124
|
+
label: AuditFeedbackLabel,
|
|
125
|
+
privacy: AuditQueryPrivacy,
|
|
126
|
+
): DecisionCase {
|
|
127
|
+
const query = privacy.include_raw_query ? audit.query : privacy.redacted_query.trim();
|
|
128
|
+
if (!query) {
|
|
129
|
+
throw new Error("A non-empty redacted_query is required when raw audit queries are excluded");
|
|
130
|
+
}
|
|
131
|
+
const parsed = rawCaseSchema.parse({
|
|
132
|
+
query,
|
|
133
|
+
split: label.split,
|
|
134
|
+
expected_outcome: label.expected_outcome,
|
|
135
|
+
relevant_skill_ids: label.relevant_skill_ids,
|
|
136
|
+
provenance: {
|
|
137
|
+
version: 1,
|
|
138
|
+
source: "audit_import",
|
|
139
|
+
review_status: "human_labelled",
|
|
140
|
+
query_storage: privacy.include_raw_query ? "raw" : "redacted",
|
|
141
|
+
audit_id: audit.id,
|
|
142
|
+
labelled_at: label.labelled_at,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return validateCase(parsed, audit.id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface DatasetProvenanceSummary {
|
|
149
|
+
version: 1;
|
|
150
|
+
human_labelled_case_count: number;
|
|
151
|
+
imported_labelled_case_count: number;
|
|
152
|
+
imported_unreviewed_case_count: number;
|
|
153
|
+
raw_query_case_count: number;
|
|
154
|
+
redacted_query_case_count: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function summarizeDatasetProvenance(
|
|
158
|
+
cases: DecisionCase[],
|
|
159
|
+
): DatasetProvenanceSummary {
|
|
160
|
+
const provenance = (item: DecisionCase): DecisionCaseProvenance =>
|
|
161
|
+
item.provenance ?? {
|
|
162
|
+
version: 1,
|
|
163
|
+
source: "authored",
|
|
164
|
+
review_status: "human_labelled",
|
|
165
|
+
query_storage: "raw",
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
version: 1,
|
|
169
|
+
human_labelled_case_count:
|
|
170
|
+
cases.filter((item) => provenance(item).review_status === "human_labelled").length,
|
|
171
|
+
imported_labelled_case_count:
|
|
172
|
+
cases.filter((item) =>
|
|
173
|
+
provenance(item).source === "audit_import" &&
|
|
174
|
+
provenance(item).review_status === "human_labelled"
|
|
175
|
+
).length,
|
|
176
|
+
imported_unreviewed_case_count:
|
|
177
|
+
cases.filter((item) =>
|
|
178
|
+
provenance(item).source === "audit_import" &&
|
|
179
|
+
provenance(item).review_status === "unreviewed"
|
|
180
|
+
).length,
|
|
181
|
+
raw_query_case_count:
|
|
182
|
+
cases.filter((item) => provenance(item).query_storage === "raw").length,
|
|
183
|
+
redacted_query_case_count:
|
|
184
|
+
cases.filter((item) => provenance(item).query_storage === "redacted").length,
|
|
185
|
+
};
|
|
71
186
|
}
|
|
72
187
|
|
|
73
188
|
// ---------------------------------------------------------------------------
|
|
@@ -113,8 +228,12 @@ function validateDatasetCompleteness(cases: DecisionCase[]): void {
|
|
|
113
228
|
* no_match → 0)
|
|
114
229
|
* - Dataset completeness (both splits, all outcome types in each split)
|
|
115
230
|
*/
|
|
116
|
-
export function loadDecisionCases(
|
|
231
|
+
export function loadDecisionCases(
|
|
232
|
+
raw: unknown[],
|
|
233
|
+
validSkillIds?: Iterable<string>,
|
|
234
|
+
): DecisionCase[] {
|
|
117
235
|
const parsed: DecisionCase[] = [];
|
|
236
|
+
const validIds = validSkillIds ? new Set(validSkillIds) : undefined;
|
|
118
237
|
|
|
119
238
|
for (let i = 0; i < raw.length; i++) {
|
|
120
239
|
const item = raw[i];
|
|
@@ -128,7 +247,17 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
|
|
|
128
247
|
);
|
|
129
248
|
}
|
|
130
249
|
|
|
131
|
-
|
|
250
|
+
const parsedCase = validateCase(result.data, i);
|
|
251
|
+
if (validIds) {
|
|
252
|
+
for (const skillId of parsedCase.relevant_skill_ids) {
|
|
253
|
+
if (!validIds.has(skillId)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Validation error at case ${i}: field "relevant_skill_ids" references unknown vault skill "${skillId}"`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
parsed.push(parsedCase);
|
|
132
261
|
}
|
|
133
262
|
|
|
134
263
|
validateDatasetCompleteness(parsed);
|
|
@@ -139,9 +268,12 @@ export function loadDecisionCases(raw: unknown[]): DecisionCase[] {
|
|
|
139
268
|
* Read a JSON file from disk and validate it as a decision-policy dataset.
|
|
140
269
|
* Throws if the file cannot be read or the contents fail validation.
|
|
141
270
|
*/
|
|
142
|
-
export function loadDecisionCasesFromFile(
|
|
271
|
+
export function loadDecisionCasesFromFile(
|
|
272
|
+
path: string,
|
|
273
|
+
validSkillIds?: Iterable<string>,
|
|
274
|
+
): DecisionCase[] {
|
|
143
275
|
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown[];
|
|
144
|
-
return loadDecisionCases(raw);
|
|
276
|
+
return loadDecisionCases(raw, validSkillIds);
|
|
145
277
|
}
|
|
146
278
|
|
|
147
279
|
// ---------------------------------------------------------------------------
|
|
@@ -734,6 +866,7 @@ export interface CalibrationRunRecord {
|
|
|
734
866
|
embedding_fingerprint: string;
|
|
735
867
|
corpus_fingerprint: string;
|
|
736
868
|
dataset_hash: string;
|
|
869
|
+
dataset_provenance?: DatasetProvenanceSummary;
|
|
737
870
|
candidate_limit: number;
|
|
738
871
|
attempt_count?: number;
|
|
739
872
|
min_auto_match_precision: number;
|
|
@@ -756,6 +889,8 @@ export interface CalibrationRunSummary {
|
|
|
756
889
|
embedding_fingerprint: string;
|
|
757
890
|
corpus_fingerprint: string;
|
|
758
891
|
dataset_hash: string;
|
|
892
|
+
human_labelled_case_count: number;
|
|
893
|
+
imported_labelled_case_count: number;
|
|
759
894
|
candidate_limit: number;
|
|
760
895
|
attempt_count: number;
|
|
761
896
|
min_auto_match_precision: number;
|
|
@@ -806,6 +941,15 @@ export function openCalibrateDb(stateDir: string): Database {
|
|
|
806
941
|
if (!columns.some((column) => column.name === "failed_reason")) {
|
|
807
942
|
db.run("ALTER TABLE calibration_runs ADD COLUMN failed_reason TEXT");
|
|
808
943
|
}
|
|
944
|
+
if (!columns.some((column) => column.name === "dataset_provenance")) {
|
|
945
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN dataset_provenance TEXT NOT NULL DEFAULT '{}'");
|
|
946
|
+
}
|
|
947
|
+
if (!columns.some((column) => column.name === "human_labelled_case_count")) {
|
|
948
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN human_labelled_case_count INTEGER NOT NULL DEFAULT 0");
|
|
949
|
+
}
|
|
950
|
+
if (!columns.some((column) => column.name === "imported_labelled_case_count")) {
|
|
951
|
+
db.run("ALTER TABLE calibration_runs ADD COLUMN imported_labelled_case_count INTEGER NOT NULL DEFAULT 0");
|
|
952
|
+
}
|
|
809
953
|
return db;
|
|
810
954
|
}
|
|
811
955
|
|
|
@@ -822,8 +966,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
822
966
|
candidate_limit,
|
|
823
967
|
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
824
968
|
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
825
|
-
selected_thresholds, tune_metrics, test_metrics, observations
|
|
826
|
-
|
|
969
|
+
selected_thresholds, tune_metrics, test_metrics, observations,
|
|
970
|
+
dataset_provenance, human_labelled_case_count, imported_labelled_case_count
|
|
971
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
827
972
|
[
|
|
828
973
|
run.run_id,
|
|
829
974
|
run.created_at,
|
|
@@ -843,6 +988,9 @@ export function insertCalibrationRun(db: Database, run: CalibrationRunRecord): v
|
|
|
843
988
|
run.tune_metrics != null ? JSON.stringify(run.tune_metrics) : null,
|
|
844
989
|
run.test_metrics != null ? JSON.stringify(run.test_metrics) : null,
|
|
845
990
|
JSON.stringify(run.observations),
|
|
991
|
+
JSON.stringify(run.dataset_provenance ?? {}),
|
|
992
|
+
run.dataset_provenance?.human_labelled_case_count ?? 0,
|
|
993
|
+
run.dataset_provenance?.imported_labelled_case_count ?? 0,
|
|
846
994
|
],
|
|
847
995
|
);
|
|
848
996
|
}
|
|
@@ -866,6 +1014,9 @@ interface RawCalibrationRow {
|
|
|
866
1014
|
tune_metrics: string | null;
|
|
867
1015
|
test_metrics: string | null;
|
|
868
1016
|
observations: string;
|
|
1017
|
+
dataset_provenance: string;
|
|
1018
|
+
human_labelled_case_count: number;
|
|
1019
|
+
imported_labelled_case_count: number;
|
|
869
1020
|
}
|
|
870
1021
|
|
|
871
1022
|
function parseMetrics(json: string): CalibrationMetrics {
|
|
@@ -900,6 +1051,10 @@ function rowToRecord(row: RawCalibrationRow): CalibrationRunRecord {
|
|
|
900
1051
|
embedding_fingerprint: row.embedding_fingerprint,
|
|
901
1052
|
corpus_fingerprint: row.corpus_fingerprint,
|
|
902
1053
|
dataset_hash: row.dataset_hash,
|
|
1054
|
+
dataset_provenance:
|
|
1055
|
+
Object.keys(JSON.parse(row.dataset_provenance) as object).length > 0
|
|
1056
|
+
? JSON.parse(row.dataset_provenance) as DatasetProvenanceSummary
|
|
1057
|
+
: undefined,
|
|
903
1058
|
candidate_limit: row.candidate_limit,
|
|
904
1059
|
attempt_count: row.attempt_count,
|
|
905
1060
|
min_auto_match_precision: row.min_auto_match_precision,
|
|
@@ -939,7 +1094,8 @@ export function listCalibrationRuns(db: Database): CalibrationRunSummary[] {
|
|
|
939
1094
|
reranker_fingerprint, embedding_fingerprint, corpus_fingerprint, dataset_hash,
|
|
940
1095
|
candidate_limit,
|
|
941
1096
|
attempt_count, min_auto_match_precision, min_auto_match_count,
|
|
942
|
-
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason
|
|
1097
|
+
min_delivered_shortlist_recall_at_k, min_shortlist_recall_at_5, failed_reason,
|
|
1098
|
+
human_labelled_case_count, imported_labelled_case_count
|
|
943
1099
|
FROM calibration_runs ORDER BY created_at DESC`,
|
|
944
1100
|
)
|
|
945
1101
|
.all() as CalibrationRunSummary[];
|
package/src/cli.ts
CHANGED
|
@@ -446,7 +446,7 @@ async function handleCalibrateCommand(
|
|
|
446
446
|
minAutoMatchCount,
|
|
447
447
|
});
|
|
448
448
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
449
|
-
|
|
449
|
+
renderCalibrationTarget(ctx.target);
|
|
450
450
|
console.log(`Calibration run complete.`);
|
|
451
451
|
if (res.result) console.log(JSON.stringify(res.result, null, 2));
|
|
452
452
|
});
|
|
@@ -456,7 +456,7 @@ async function handleCalibrateCommand(
|
|
|
456
456
|
if (sub === "list") {
|
|
457
457
|
const res = await adapter.calibrateList();
|
|
458
458
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
459
|
-
|
|
459
|
+
renderCalibrationTarget(ctx.target);
|
|
460
460
|
renderTable(
|
|
461
461
|
[
|
|
462
462
|
{ key: "run_id", header: "RUN_ID" },
|
|
@@ -474,7 +474,7 @@ async function handleCalibrateCommand(
|
|
|
474
474
|
if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
|
|
475
475
|
const res = await adapter.calibrateShow(runId);
|
|
476
476
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
477
|
-
|
|
477
|
+
renderCalibrationTarget(ctx.target);
|
|
478
478
|
console.log(JSON.stringify(res, null, 2));
|
|
479
479
|
});
|
|
480
480
|
return;
|
|
@@ -485,7 +485,7 @@ async function handleCalibrateCommand(
|
|
|
485
485
|
if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
|
|
486
486
|
const res = await adapter.calibrateApply(runId);
|
|
487
487
|
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
488
|
-
|
|
488
|
+
renderCalibrationTarget(ctx.target);
|
|
489
489
|
console.log(`Applied calibration run "${runId}"`);
|
|
490
490
|
});
|
|
491
491
|
return;
|
|
@@ -501,6 +501,14 @@ async function handleCalibrateCommand(
|
|
|
501
501
|
);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
function renderCalibrationTarget(target: ResolvedTarget): void {
|
|
505
|
+
if (target.type === "local") {
|
|
506
|
+
console.log("Target: local");
|
|
507
|
+
} else {
|
|
508
|
+
console.log(`Target: remote (${target.name} -> ${target.server})`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
504
512
|
async function handleCompletionsCommand(shell: string) {
|
|
505
513
|
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
506
514
|
throw new Error("usage: skillmux completions <bash|zsh|fish>");
|
package/src/dataset-generator.ts
CHANGED
|
@@ -13,14 +13,45 @@ export interface GenerateDatasetOptions {
|
|
|
13
13
|
queriesPerSplit?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
const STOP_WORDS = new Set([
|
|
17
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "is",
|
|
18
|
+
"it", "of", "on", "or", "the", "this", "to", "use", "with",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
function words(value: string): string[] {
|
|
22
|
+
return value
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.match(/[a-z0-9]+/g)
|
|
25
|
+
?.filter((word) => word.length > 2 && !STOP_WORDS.has(word)) ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function anchors(skill: VaultSkill): string[] {
|
|
29
|
+
const preferred = [...skill.aliases.flatMap(words), ...words(skill.title)];
|
|
30
|
+
const fallback = words(skill.description);
|
|
31
|
+
return [...new Set([...preferred, ...fallback])].slice(0, 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchedQuery(skill: VaultSkill, variant: number): string {
|
|
35
|
+
const [first = "specialized", second = "workflow"] = anchors(skill);
|
|
36
|
+
const templates = [
|
|
37
|
+
`I need practical guidance completing an unfamiliar ${first} ${second} task safely`,
|
|
38
|
+
`Which available workflow can handle my unusual ${first} ${second} problem end to end`,
|
|
39
|
+
`Please guide me through a difficult unfamiliar ${first} ${second} operation safely`,
|
|
40
|
+
];
|
|
41
|
+
return templates[variant % templates.length]!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ambiguousQuery(first: VaultSkill, second: VaultSkill): string {
|
|
45
|
+
const [firstAnchor = "first"] = anchors(first);
|
|
46
|
+
const [secondAnchor = "second"] = anchors(second);
|
|
47
|
+
return `Help with a workflow spanning both ${firstAnchor} and ${secondAnchor} responsibilities`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nearMissQuery(first: VaultSkill, second: VaultSkill): string {
|
|
51
|
+
const [firstAnchor = "one"] = anchors(first);
|
|
52
|
+
const [secondAnchor = "another"] = anchors(second);
|
|
53
|
+
return `Explain the theory comparing ${firstAnchor} and ${secondAnchor} without performing either workflow`;
|
|
54
|
+
}
|
|
24
55
|
|
|
25
56
|
/**
|
|
26
57
|
* Automatically generate a synthetic decision-policy calibration dataset
|
|
@@ -30,110 +61,58 @@ export function generateDataset(
|
|
|
30
61
|
skills: VaultSkill[],
|
|
31
62
|
options: GenerateDatasetOptions = {},
|
|
32
63
|
): RawDecisionCase[] {
|
|
33
|
-
|
|
64
|
+
if (skills.length < 4) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Dataset generation requires at least 4 vault skills so tune and test can each contain matched and ambiguous cases without skill leakage",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
34
69
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
70
|
+
const cases: RawDecisionCase[] = [];
|
|
71
|
+
const sorted = [...skills].sort((a, b) => a.skill_id.localeCompare(b.skill_id));
|
|
72
|
+
const splitAt = Math.ceil(sorted.length / 2);
|
|
73
|
+
const bySplit: Record<DecisionSplit, VaultSkill[]> = {
|
|
74
|
+
tune: sorted.slice(0, splitAt),
|
|
75
|
+
test: sorted.slice(splitAt),
|
|
76
|
+
};
|
|
77
|
+
const targetPerSplit = Math.max(3, options.queriesPerSplit ?? 10);
|
|
44
78
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
split: "test",
|
|
50
|
-
expected_outcome: "matched",
|
|
51
|
-
relevant_skill_ids: [skill.skill_id],
|
|
52
|
-
});
|
|
53
|
-
} else {
|
|
79
|
+
for (const split of ["tune", "test"] as const) {
|
|
80
|
+
const splitSkills = bySplit[split];
|
|
81
|
+
for (let i = 0; i < splitSkills.length; i++) {
|
|
82
|
+
const skill = splitSkills[i]!;
|
|
54
83
|
cases.push({
|
|
55
|
-
query:
|
|
56
|
-
split
|
|
84
|
+
query: matchedQuery(skill, i),
|
|
85
|
+
split,
|
|
57
86
|
expected_outcome: "matched",
|
|
58
87
|
relevant_skill_ids: [skill.skill_id],
|
|
59
88
|
});
|
|
60
89
|
}
|
|
61
|
-
}
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// Pair skills for ambiguous multi-match
|
|
66
|
-
for (let i = 0; i < skills.length - 1; i += 2) {
|
|
67
|
-
const s1 = skills[i]!;
|
|
68
|
-
const s2 = skills[i + 1]!;
|
|
69
|
-
const split: DecisionSplit = i % 4 === 0 ? "tune" : "test";
|
|
70
|
-
cases.push({
|
|
71
|
-
query: `automated task using ${s1.title} and ${s2.title}`,
|
|
72
|
-
split,
|
|
73
|
-
expected_outcome: "ambiguous",
|
|
74
|
-
relevant_skill_ids: [s1.skill_id, s2.skill_id],
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
} else {
|
|
78
|
-
// Fallback ambiguous cases if fewer than 2 skills
|
|
79
|
-
cases.push({
|
|
80
|
-
query: "automate browser workflow testing",
|
|
81
|
-
split: "tune",
|
|
82
|
-
expected_outcome: "ambiguous",
|
|
83
|
-
relevant_skill_ids: ["mock-e2e", "mock-browser"],
|
|
84
|
-
});
|
|
85
|
-
cases.push({
|
|
86
|
-
query: "extract and fetch clean web text",
|
|
87
|
-
split: "test",
|
|
88
|
-
expected_outcome: "ambiguous",
|
|
89
|
-
relevant_skill_ids: ["mock-fetch", "mock-extract"],
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Ensure both tune and test have ambiguous cases
|
|
94
|
-
if (!cases.some((c) => c.split === "tune" && c.expected_outcome === "ambiguous")) {
|
|
95
|
-
const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
|
|
91
|
+
const first = splitSkills[0]!;
|
|
92
|
+
const second = splitSkills[1]!;
|
|
96
93
|
cases.push({
|
|
97
|
-
query:
|
|
98
|
-
split
|
|
94
|
+
query: ambiguousQuery(first, second),
|
|
95
|
+
split,
|
|
99
96
|
expected_outcome: "ambiguous",
|
|
100
|
-
relevant_skill_ids:
|
|
97
|
+
relevant_skill_ids: [first.skill_id, second.skill_id],
|
|
101
98
|
});
|
|
102
|
-
}
|
|
103
|
-
if (!cases.some((c) => c.split === "test" && c.expected_outcome === "ambiguous")) {
|
|
104
|
-
const sIds = skills.length >= 2 ? [skills[0]!.skill_id, skills[1]!.skill_id] : ["mock-a", "mock-b"];
|
|
105
99
|
cases.push({
|
|
106
|
-
query:
|
|
107
|
-
split
|
|
108
|
-
expected_outcome: "ambiguous",
|
|
109
|
-
relevant_skill_ids: sIds,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// --- 3. No Match Cases ---
|
|
114
|
-
GENERIC_NO_MATCH_QUERIES.forEach((q, idx) => {
|
|
115
|
-
cases.push({
|
|
116
|
-
query: q,
|
|
117
|
-
split: idx % 2 === 0 ? "tune" : "test",
|
|
100
|
+
query: nearMissQuery(first, second),
|
|
101
|
+
split,
|
|
118
102
|
expected_outcome: "no_match",
|
|
119
103
|
relevant_skill_ids: [],
|
|
120
104
|
});
|
|
121
|
-
});
|
|
122
105
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
split: "test",
|
|
134
|
-
expected_outcome: "matched",
|
|
135
|
-
relevant_skill_ids: ["mock-docs"],
|
|
136
|
-
});
|
|
106
|
+
for (let i = cases.filter((item) => item.split === split).length; i < targetPerSplit; i++) {
|
|
107
|
+
const left = splitSkills[i % splitSkills.length]!;
|
|
108
|
+
const right = splitSkills[(i + 1) % splitSkills.length]!;
|
|
109
|
+
cases.push({
|
|
110
|
+
query: i % 2 === 0 ? matchedQuery(left, i) : nearMissQuery(left, right),
|
|
111
|
+
split,
|
|
112
|
+
expected_outcome: i % 2 === 0 ? "matched" : "no_match",
|
|
113
|
+
relevant_skill_ids: i % 2 === 0 ? [left.skill_id] : [],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
137
116
|
}
|
|
138
117
|
|
|
139
118
|
return cases;
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createClients } from "./clients";
|
|
7
|
-
import {
|
|
7
|
+
import { loadConfig, resolveConfigPath } from "./config";
|
|
8
8
|
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
9
9
|
import { RuntimeSnapshotManager } from "./snapshot";
|
|
10
10
|
import {
|
|
@@ -28,11 +28,6 @@ import {
|
|
|
28
28
|
RELOADABLE_KEYS,
|
|
29
29
|
RESTART_REQUIRED_KEYS,
|
|
30
30
|
} from "./config-service";
|
|
31
|
-
import {
|
|
32
|
-
applyCalibrationRun,
|
|
33
|
-
getCalibrationRun,
|
|
34
|
-
listCalibrationRuns,
|
|
35
|
-
} from "./calibrate";
|
|
36
31
|
|
|
37
32
|
export const metricsRegistry = new MetricsRegistry();
|
|
38
33
|
export const readinessState = new ReadinessState();
|
|
@@ -381,7 +376,7 @@ export async function startServer(opts?: {
|
|
|
381
376
|
JSON.stringify({
|
|
382
377
|
config_read: true,
|
|
383
378
|
config_write: !isExternallyManaged,
|
|
384
|
-
calibration:
|
|
379
|
+
calibration: false,
|
|
385
380
|
persistence: isExternallyManaged
|
|
386
381
|
? "externally_managed"
|
|
387
382
|
: "writable",
|
|
@@ -465,70 +460,15 @@ export async function startServer(opts?: {
|
|
|
465
460
|
}
|
|
466
461
|
|
|
467
462
|
if (url.pathname.startsWith("/admin/v1/calibrations")) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
headers,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
const runIdMatch = url.pathname.match(
|
|
480
|
-
/^\/admin\/v1\/calibrations\/([^\/]+)$/,
|
|
481
|
-
);
|
|
482
|
-
if (req.method === "GET" && runIdMatch && runIdMatch[1]) {
|
|
483
|
-
const runId = runIdMatch[1];
|
|
484
|
-
const run = getCalibrationRun(db, runId);
|
|
485
|
-
if (!run)
|
|
486
|
-
return new Response(
|
|
487
|
-
JSON.stringify({ error: "Calibration run not found" }),
|
|
488
|
-
{ status: 404, headers },
|
|
489
|
-
);
|
|
490
|
-
return new Response(JSON.stringify(run), {
|
|
491
|
-
status: 200,
|
|
492
|
-
headers,
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
if (
|
|
496
|
-
req.method === "POST" &&
|
|
497
|
-
url.pathname === "/admin/v1/calibrations"
|
|
498
|
-
) {
|
|
499
|
-
const runId = "run_" + Math.random().toString(36).slice(2, 10);
|
|
500
|
-
return new Response(
|
|
501
|
-
JSON.stringify({ run_id: runId, status: "running" }),
|
|
502
|
-
{ status: 202, headers },
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
const applyMatch = url.pathname.match(
|
|
506
|
-
/^\/admin\/v1\/calibrations\/([^\/]+)\/apply$/,
|
|
463
|
+
return new Response(
|
|
464
|
+
JSON.stringify({
|
|
465
|
+
error: "not_implemented",
|
|
466
|
+
message:
|
|
467
|
+
"Remote calibration is not implemented in this release. " +
|
|
468
|
+
"Run `skillmux calibrate` against a local target.",
|
|
469
|
+
}),
|
|
470
|
+
{ status: 501, headers },
|
|
507
471
|
);
|
|
508
|
-
if (req.method === "POST" && applyMatch && applyMatch[1]) {
|
|
509
|
-
const runId = applyMatch[1];
|
|
510
|
-
const run = getCalibrationRun(db, runId);
|
|
511
|
-
if (!run)
|
|
512
|
-
return new Response(
|
|
513
|
-
JSON.stringify({ error: "Calibration run not found" }),
|
|
514
|
-
{ status: 404, headers },
|
|
515
|
-
);
|
|
516
|
-
const active = snapshots.acquire();
|
|
517
|
-
const currentRerankerFingerprint = rerankerFingerprint(
|
|
518
|
-
active.snapshot.config,
|
|
519
|
-
);
|
|
520
|
-
active.release();
|
|
521
|
-
await applyCalibrationRun(
|
|
522
|
-
db,
|
|
523
|
-
runId,
|
|
524
|
-
expandHome(configPath),
|
|
525
|
-
{ currentRerankerFingerprint },
|
|
526
|
-
);
|
|
527
|
-
return new Response(JSON.stringify({ ok: true, run_id: runId }), {
|
|
528
|
-
status: 200,
|
|
529
|
-
headers,
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
472
|
}
|
|
533
473
|
|
|
534
474
|
return new Response("Not Found", { status: 404, headers });
|