@klhapp/skillmux 1.1.0 → 1.3.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/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 as DecisionCase;
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(raw: unknown[]): DecisionCase[] {
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
- parsed.push(validateCase(result.data, i));
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(path: string): DecisionCase[] {
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
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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
@@ -132,6 +132,31 @@ const KNOWN_COMMANDS = [
132
132
  "local-vault",
133
133
  ];
134
134
 
135
+ const DOCKER_HOST_MANAGEMENT_GUIDANCE =
136
+ "This command manages local Skillmux or agent directories and is not supported inside the Docker image. Install the Skillmux CLI on the host using the Bun package or standalone Linux executable.";
137
+
138
+ function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
139
+ if (
140
+ [
141
+ "init",
142
+ "sync",
143
+ "install",
144
+ "project",
145
+ "target",
146
+ "core",
147
+ "local-vault",
148
+ "models",
149
+ "context",
150
+ "calibrate",
151
+ "eval",
152
+ ].includes(command)
153
+ ) {
154
+ return true;
155
+ }
156
+
157
+ return command === "config" && ["init", "set"].includes(subCommand);
158
+ }
159
+
135
160
  async function main() {
136
161
  const rawArgv = Bun.argv.slice(2);
137
162
 
@@ -141,6 +166,8 @@ async function main() {
141
166
  let flagContext: string | undefined;
142
167
  let flagServer: string | undefined;
143
168
  let isDryRun = false;
169
+ const subCommand = rawArgv[1] ?? "";
170
+ const commandArgs = rawArgv.slice(2);
144
171
 
145
172
  const command = rawArgv[0];
146
173
  if (!command || command === "--help" || command === "-h") {
@@ -161,6 +188,18 @@ async function main() {
161
188
 
162
189
  let resolvedTarget: ResolvedTarget = { type: "local", name: "local" };
163
190
 
191
+ if (
192
+ process.env.RUNNING_IN_DOCKER === "true" &&
193
+ isDockerHostManagementCommand(command, subCommand)
194
+ ) {
195
+ handleError(new Error(DOCKER_HOST_MANAGEMENT_GUIDANCE), {
196
+ target: resolvedTarget,
197
+ isJson,
198
+ isVerbose,
199
+ });
200
+ return;
201
+ }
202
+
164
203
  // Only resolve target if command is target-aware or context/config/calibrate
165
204
  const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
166
205
  if (
@@ -181,8 +220,6 @@ async function main() {
181
220
  }
182
221
 
183
222
  const adapter = createTargetAdapter(resolvedTarget, { allowInsecure });
184
- const subCommand = rawArgv[1] ?? "";
185
- const commandArgs = rawArgv.slice(2);
186
223
 
187
224
  try {
188
225
  switch (command) {
@@ -446,7 +483,7 @@ async function handleCalibrateCommand(
446
483
  minAutoMatchCount,
447
484
  });
448
485
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
449
- renderTargetBanner(ctx.target);
486
+ renderCalibrationTarget(ctx.target);
450
487
  console.log(`Calibration run complete.`);
451
488
  if (res.result) console.log(JSON.stringify(res.result, null, 2));
452
489
  });
@@ -456,7 +493,7 @@ async function handleCalibrateCommand(
456
493
  if (sub === "list") {
457
494
  const res = await adapter.calibrateList();
458
495
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
459
- renderTargetBanner(ctx.target);
496
+ renderCalibrationTarget(ctx.target);
460
497
  renderTable(
461
498
  [
462
499
  { key: "run_id", header: "RUN_ID" },
@@ -474,7 +511,7 @@ async function handleCalibrateCommand(
474
511
  if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
475
512
  const res = await adapter.calibrateShow(runId);
476
513
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
477
- renderTargetBanner(ctx.target);
514
+ renderCalibrationTarget(ctx.target);
478
515
  console.log(JSON.stringify(res, null, 2));
479
516
  });
480
517
  return;
@@ -485,7 +522,7 @@ async function handleCalibrateCommand(
485
522
  if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
486
523
  const res = await adapter.calibrateApply(runId);
487
524
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
488
- renderTargetBanner(ctx.target);
525
+ renderCalibrationTarget(ctx.target);
489
526
  console.log(`Applied calibration run "${runId}"`);
490
527
  });
491
528
  return;
@@ -501,6 +538,14 @@ async function handleCalibrateCommand(
501
538
  );
502
539
  }
503
540
 
541
+ function renderCalibrationTarget(target: ResolvedTarget): void {
542
+ if (target.type === "local") {
543
+ console.log("Target: local");
544
+ } else {
545
+ console.log(`Target: remote (${target.name} -> ${target.server})`);
546
+ }
547
+ }
548
+
504
549
  async function handleCompletionsCommand(shell: string) {
505
550
  if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
506
551
  throw new Error("usage: skillmux completions <bash|zsh|fish>");
@@ -1,4 +1,4 @@
1
- import { watch } from "node:fs";
1
+ import { mkdirSync, watch } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { loadConfig } from "./config";
4
4
  import type { Config } from "./types";
@@ -133,6 +133,10 @@ export class ConfigWatcher {
133
133
  const dir = dirname(tomlPath);
134
134
  const filename = tomlPath.split(/[/\\]/).pop()!;
135
135
 
136
+ // A config file is optional. Ensure its parent exists so zero-config
137
+ // startup is safe and later config writes are still observed.
138
+ mkdirSync(dir, { recursive: true });
139
+
136
140
  this.watcher = watch(dir, { recursive: false }, (_event, changedName) => {
137
141
  if (this.stopped) return;
138
142
  // Fire for: the config file itself, or any .tmp variant of it (handles
@@ -13,14 +13,45 @@ export interface GenerateDatasetOptions {
13
13
  queriesPerSplit?: number;
14
14
  }
15
15
 
16
- const GENERIC_NO_MATCH_QUERIES = [
17
- "what is the weather in Paris today",
18
- "recipe for baking sourdough bread at home",
19
- "what is the distance from Earth to Mars",
20
- "explain quantum entanglement simply",
21
- "how do I solve a quadratic equation",
22
- "who won the 1998 World Cup",
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
- const cases: RawDecisionCase[] = [];
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
- // --- 1. Matched Cases ---
36
- for (const skill of skills) {
37
- // Primary query from title + description
38
- cases.push({
39
- query: `how do I ${skill.title.toLowerCase()}: ${skill.description.toLowerCase()}`,
40
- split: "tune",
41
- expected_outcome: "matched",
42
- relevant_skill_ids: [skill.skill_id],
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
- // Secondary query from aliases
46
- if (skill.aliases.length > 0) {
47
- cases.push({
48
- query: `help me with ${skill.aliases[0]}`,
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: `execute task related to ${skill.title}`,
56
- split: "test",
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
- // --- 2. Ambiguous Cases ---
64
- if (skills.length >= 2) {
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: "integrated workflow multi skill query",
98
- split: "tune",
94
+ query: ambiguousQuery(first, second),
95
+ split,
99
96
  expected_outcome: "ambiguous",
100
- relevant_skill_ids: sIds,
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: "combined operations multi skill query",
107
- split: "test",
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
- // Ensure both splits have at least 1 matched case if skills were empty
124
- if (skills.length === 0) {
125
- cases.push({
126
- query: "run mock container action",
127
- split: "tune",
128
- expected_outcome: "matched",
129
- relevant_skill_ids: ["mock-container"],
130
- });
131
- cases.push({
132
- query: "search mock API docs",
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;